<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall;

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

use SiteFort\Includes\IpDetector;
use SiteFort\Includes\SitePathResolver;
use SiteFort\Modules\Firewall\RateLimiter\SlidingWindowStore;

/**
 * Application-level sensitive path probe detector.
 *
 * Blocks requests to sensitive files (.env, .git, wp-config backups, plugin/theme
 * readme.txt, etc.) at the PHP level. This is the belt-and-suspenders complement
 * to the server-level .htaccess/nginx rules written by SystemTweaks.
 *
 * Uses bounded event logging: every sensitive path request is denied with 403,
 * but only the first probe and the ban escalation inside a configured window
 * are written to the firewall event table. These rows are written directly so
 * notifications, cloud state pushes, and external block sync hooks are not
 * triggered by high-volume probe traffic.
 */
class SensitivePathDetector {

    public const CLASS_ASSET  = 'asset';
    public const CLASS_STRONG = 'strong';
    public const CLASS_WEAK   = 'weak';

    public const SEVERITY_PROBE = 'probe';
    public const SEVERITY_DENY  = 'deny';

    /**
     * Path-independent probe signatures.
     *
     * @var string[]
     */
    private const STATIC_PATTERNS = [
        // Dotfiles and VCS directories.
        '#/\.env($|\.)#i',
        '#/\.git(/|$)#i',
        '#/\.svn(/|$)#i',
        '#/\.hg(/|$)#i',
        '#/\.htaccess$#i',
        '#/\.htpasswd$#i',
        '#/\.DS_Store$#i',
        '#/\.user\.ini$#i',

        // WordPress config backups / variants.
        '#/wp-config\.(php\.bak|old|txt|php~|save|orig|copy|php\.swp)$#i',
        '#/wp-config\.php\.[\d]+$#i',
    ];

    // Visitor noise (stale links, broken themes); never counted.
    private const ASSET_404_PATTERN = '#\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?|woff2?|ttf|otf|eot|mp3|m4a|mp4|m4v|webm|ogv|ogg|oga|wav|mov|avi|pdf)$#i';

    // Paths that never exist on a real site; matched against 404s only.
    private const STRONG_404_PATTERNS = [
        // Dotfiles; .well-known excluded, browsers request it on their own.
        '#/\.(?!well-known(?:/|$))[^/]+#i',
        // Stacks WordPress never serves.
        '#\.(?:asp|aspx|jsp|jspx|cgi|pl|cfm|sh|ya?ml)$#i',
        // Database dumps, leftover configs, editor backups.
        '#\.sql(?:\.(?:gz|bz2|zip))?$#i',
        '#\.(?:log|ini|inc|bak|old|orig|save|swp|dist|conf|sqlite3?|db)$#i',
        '#~$#',
        // Nested-install discovery.
        '#/(?:wp-admin|wp-includes)/#i',
        '#/wlwmanifest\.xml$#i',
        '#/setup-config\.php$#i',
        // Known tool and webshell names.
        '#/adminer[^/]*\.php$#i',
        '#/(?:phpmyadmin[^/]*|pma\d*|myadmin|mysqladmin)(?:/|$)#i',
        '#/(?:shell|wso|alfa|c99|r57|b374k|indoxploit|eval-stdin)[^/]*\.php$#i',
        '#/vendor/phpunit/#i',
        // Router/IoT and non-WP stack probes.
        '#/(?:cgi-bin|hnap1|boaform|gponform|stalker_portal)(?:/|$)#i',
        '#/(?:solr|actuator|telescope|_profiler|_ignition)/#i',
        '#/etc/passwd#i',
    ];

    // Plausible as stale links; probe volume only, never the ban ladder.
    private const WEAK_404_PATTERNS = [
        '#\.php$#i',
        '#\.(?:zip|tar|t?gz|rar|7z|bz2)$#i',
    ];

    /**
     * Number of probes from one IP before the block action fires.
     *
     * @var int
     */
    private int $threshold;

    /**
     * Time window in seconds within which probes must occur.
     *
     * @var int
     */
    private int $window;

    /**
     * Ban duration in days.
     *
     * @var int
     */
    private int $ban_duration;

    private SlidingWindowStore $store;

    private ?string $severity_cache_path = null;

    private ?string $severity_cache = null;

    /**
     * @param int $threshold    Probe count before escalating to an IP ban.
     * @param int $window       Time window in seconds (default 300 = 5 minutes).
     * @param int $ban_duration Ban duration in days.
     */
    public function __construct( int $threshold = 3, int $window = 300, int $ban_duration = 0 ) {
        $this->threshold    = max( 1, $threshold );
        $this->window       = max( 60, $window );
        $this->ban_duration = max( 0, $ban_duration );
        $this->store        = new SlidingWindowStore();
    }

    public function matches_request(): bool {
        return $this->get_match_severity() !== null;
    }

    // PROBE counts toward the ban threshold; DENY is denied but never counts,
    // since the backup-name heuristic can misfire on real content.
    public function get_match_severity(): ?string {
        $uri = self::current_request_path();
        if ( $uri === '' ) {
            return null;
        }

        if ( $this->severity_cache_path === $uri ) {
            return $this->severity_cache;
        }

        $matched_deny_only = false;
        $result            = null;

        foreach ( $this->get_patterns() as $pattern => $severity ) {
            if ( preg_match( $pattern, $uri ) !== 1 ) {
                continue;
            }

            if ( $severity === self::SEVERITY_PROBE ) {
                $result = self::SEVERITY_PROBE;
                break;
            }

            $matched_deny_only = true;
        }

        if ( $result === null && $matched_deny_only ) {
            $result = self::SEVERITY_DENY;
        }

        $this->severity_cache_path = $uri;
        $this->severity_cache      = $result;

        return $result;
    }

    /**
     * Deny a matched sensitive path request. Pass $countable false for
     * verified search engines and safe services: they still get the 403,
     * but an IP ban would outrank their bypass and block them site-wide.
     */
    public function enforce_match( bool $countable = true ): void {
        $uri = self::current_request_path();
        if ( $uri === '' ) {
            return;
        }

        // Backup-shaped names are denied but never counted: see get_match_severity().
        if ( $this->get_match_severity() !== self::SEVERITY_PROBE ) {
            $countable = false;
        }

        if ( $countable ) {
            $this->register_probe( $uri, 'Sensitive Path', 403 );
        }

        // Always deny access to sensitive files regardless of threshold.
        if ( ! headers_sent() ) {
            http_response_code( 403 );
            header( 'Content-Type: text/html; charset=UTF-8' );
            header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
        }
        require_once __DIR__ . '/block-page.php';
        sitefort_render_block_page( 403, 'This request was blocked due to suspicious activity.', IpDetector::get_ip() );
        exit;
    }

    /**
     * Count a scanner-shaped 404 toward the same ban threshold as direct
     * sensitive path hits. The response stays a 404; nothing is blocked here.
     */
    public function record_probe_404( string $path ): void {
        if ( $path === '' ) {
            return;
        }

        $this->register_probe( $path, 'Scanner Probe', 404 );
    }

    /**
     * Classify a 404 request path by scanner likelihood.
     *
     * @return string|null CLASS_ASSET, CLASS_STRONG, CLASS_WEAK, or null for
     *                     ordinary page 404s.
     */
    public static function classify_404_path( string $path ): ?string {
        if ( $path === '' || $path === '/' ) {
            return null;
        }

        if ( preg_match( self::ASSET_404_PATTERN, $path ) === 1 ) {
            return self::CLASS_ASSET;
        }

        foreach ( self::STRONG_404_PATTERNS as $pattern ) {
            if ( preg_match( $pattern, $path ) === 1 ) {
                return self::CLASS_STRONG;
            }
        }

        foreach ( self::WEAK_404_PATTERNS as $pattern ) {
            if ( preg_match( $pattern, $path ) === 1 ) {
                return self::CLASS_WEAK;
            }
        }

        return null;
    }

    /**
     * Build request-path-aware patterns for the current site layout.
     *
     * @return array<string, string> Pattern => severity.
     */
    private function get_patterns(): array {
        $patterns = [];
        foreach ( self::STATIC_PATTERNS as $pattern ) {
            $patterns[ $pattern ] = self::SEVERITY_PROBE;
        }

        $site_root    = preg_quote( SitePathResolver::get_site_root_path(), '#' );
        $content_root = SitePathResolver::get_content_public_path( '' );

        // Dumps are never legitimate content. Archives are, so they only count as
        // backups in a backup dir or with a delimited backup token in the name;
        // content words and bare dates stay reachable.
        $dump_exts    = '(?:sql|sql\.gz|sql\.bz2|sql\.zip)';
        $archive_exts = '(?:zip|tar|t?gz|tar\.gz|tar\.bz2|rar|7z)';
        $backup_dirs  = '(?:backup|backups|bak|db-backup|db-backups|database-backup|database-backups|dump|dumps|old-site|site-backup)';
        $backup_token = '(?:backup|bak|dump|db[-_.]?dump|db[-_.]?backup)';
        // Delimited so "bakery"/"dumpling" don't match.
        $backup_name  = '(?:[^/]*[-_.])?' . $backup_token . '(?:[-_.][^/]*)?';

        $patterns[ '#^' . $site_root . '(debug\.log|error_log|error\.log)$#i' ] = self::SEVERITY_PROBE;
        $patterns[ '#^' . $site_root . 'phpinfo\.php$#i' ]                      = self::SEVERITY_PROBE;
        $patterns[ '#^' . $site_root . 'readme\.html?$#i' ]                     = self::SEVERITY_PROBE;

        $install_path = SitePathResolver::get_admin_public_path( 'install.php' );
        if ( is_string( $install_path ) && $install_path !== '' ) {
            $patterns[ self::build_exact_path_pattern( $install_path ) ] = self::SEVERITY_PROBE;
        }

        $roots = [ $site_root ];
        if ( is_string( $content_root ) && $content_root !== '' ) {
            $content_regex = preg_quote( $content_root, '#' );
            $roots[]       = $content_regex;

            $patterns[ '#^' . $content_regex . '(plugins|themes)/[^/]+/readme\.txt$#i' ] = self::SEVERITY_PROBE;
        }

        foreach ( $roots as $root ) {
            $patterns[ '#^' . $root . '(?:[^?]+/)?[^/]+\.' . $dump_exts . '$#i' ] = self::SEVERITY_PROBE;
            $patterns[ '#^' . $root . '(?:[^?]+/)?' . $backup_dirs . '/[^?]+\.' . $archive_exts . '$#i' ] = self::SEVERITY_DENY;
            $patterns[ '#^' . $root . '(?:[^?]+/)?' . $backup_name . '\.' . $archive_exts . '$#i' ] = self::SEVERITY_DENY;
        }

        return $patterns;
    }

    private static function build_exact_path_pattern( string $path ): string {
        return '#^' . preg_quote( SitePathResolver::normalize_request_path( $path, false ), '#' ) . '$#i';
    }

    /**
     * Preserve request path bytes for matching while removing invalid controls.
     *
     * @param mixed $value
     */
    public static function sanitize_server_request_scalar( $value ): string {
        if ( ! is_scalar( $value ) ) {
            return '';
        }

        $value = str_replace( "\0", '', (string) $value );
        $value = preg_replace( '/[\x00-\x1F\x7F]/', '', $value );

        return is_string( $value ) ? $value : '';
    }

    /**
     * The threshold check is >= because concurrent probes can leapfrog the
     * exact value; the escalation row is only logged when the ban landed,
     * so duplicate attempts stay silent.
     */
    private function register_probe( string $uri, string $attack_type, int $http_status ): void {
        $ip    = IpDetector::get_ip();
        $count = $this->increment_probe_count( $ip );

        if ( $count === 1 && $this->threshold > 1 ) {
            $this->log_sensitive_path_event( $ip, $uri, $attack_type, $http_status );
        }

        if ( $count >= $this->threshold && $this->ban_ip( $ip, $count, $uri ) ) {
            $this->log_sensitive_path_event( $ip, $uri, 'Sensitive Path Escalation', 403 );
        }
    }

    /**
     * Write a DB-only firewall traffic row without firing notification/cloud hooks.
     */
    private function log_sensitive_path_event( string $ip, string $uri, string $attack_type, int $http_status ): void {
        FirewallLogger::log( [
            'site_url'    => home_url(),
            'ip'          => $ip,
            'attack_type' => $attack_type,
            'http_status' => $http_status,
            'request_uri' => $uri,
        ] );
    }

    /**
     * Add the probing IP to the structured firewall lockout list.
     */
    private function ban_ip( string $ip, int $count, string $uri ): bool {
        if ( $ip === '' ) {
            return false;
        }

        if ( ! \SiteFort\Modules\Firewall\SearchTrust\SearchTrustBanGate::allows_auto_ban( $ip ) ) {
            return false;
        }

        $manager = new LockoutManager();
        $result  = $manager->add(
            $ip,
            'ban',
            [
                'source'   => 'sensitive_path',
                'reason'   => sprintf(
                    'Sensitive path probe threshold reached (%d requests within %d seconds). Last URI: %s',
                    $count,
                    $this->window,
                    substr( $uri, 0, 190 )
                ),
                'duration' => $this->ban_duration,
            ]
        );

        return ! empty( $result['success'] );
    }

    /**
     * Increment and return the probe count for an IP within the configured window.
     */
    private function increment_probe_count( string $ip ): int {
        $ip_hash = hash( 'sha256', $ip );
        $minutes = max( 1, (int) ceil( $this->window / 60 ) );

        $this->store->increment( $ip_hash, 'sensitive_path' );

        return $this->store->get_count( $ip_hash, 'sensitive_path', $minutes );
    }

    /**
     * Extract the normalized path component from the request URI.
     */
    public static function current_request_path(): string {
        $uri = filter_input(
            INPUT_SERVER,
            'REQUEST_URI',
            FILTER_CALLBACK,
            [ 'options' => [ self::class, 'sanitize_server_request_scalar' ] ]
        );
        $uri = is_string( $uri ) ? $uri : '';
        if ( $uri === '' ) {
            return '';
        }

        // Strip query string.
        $path = wp_parse_url( $uri, PHP_URL_PATH );
        if ( ! is_string( $path ) || $path === '' ) {
            return '';
        }

        // Decode once so %2E-style encoding cannot slip past the patterns,
        // then re-strip controls the decode may have reintroduced.
        $path = self::sanitize_server_request_scalar( rawurldecode( $path ) );
        if ( $path === '' ) {
            return '';
        }

        return SitePathResolver::normalize_request_path( $path, false );
    }
}
