<?php
/**
 * AntivirusEngine — Core scanning engine for the Antivirus module.
 *
 * Detection layers:
 *   1. MD5 hash matching — known malware signatures (fastest)
 *   2. Critical PHP heuristic patterns — web shells, eval+base64, RFI
 *   3. Suspicious PHP patterns — obfuscation, hidden eval chains
 *   4. Polymorphic obfuscation detection — variable-variable, chr() chains
 *   5. Supply-chain / dropper patterns — self-modifying, cURL backdoors
 *   6. JavaScript malicious patterns — crypto miners, obfuscated eval, skimmers
 *   7. HTML injection patterns — hidden iframes, malicious <script> injections
 *   8. WordPress core integrity — SHA/MD5 checksums via api.wordpress.org
 *   9. Entropy analysis — high-entropy strings typical of encoded payloads
 *  10. Plugin/theme vulnerability detection — local DB + optional WPVulnDB API
 *
 * @package UltraguardPro\modules\antivirus
 */

namespace UltraguardPro\modules\antivirus;

use UltraguardPro\modules\antivirus\db\ScanResultsTable;
use UltraguardPro\modules\antivirus\db\MalwareSignaturesTable;
use UltraguardPro\modules\antivirus\db\QuarantineTable;
use UltraguardPro\support\SecureRemoteTransport;

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

class AntivirusEngine {

    private const CHUNK_SIZE         = 50;
    private const MAX_MATCH_FILESIZE = 2097152;
    private const PROGRESS_TRANSIENT = 'ultraguard_av_scan_progress';
    private const RUNNING_TRANSIENT  = 'ultraguard_av_scan_running';
    private const CURSOR_TRANSIENT   = 'ultraguard_av_scan_cursor';

    // ── Critical PHP patterns ─────────────────────────────────────────────────

    /** Any match = immediate flag as critical threat. */
    private const CRITICAL_PATTERNS = [
        // Classic web shells — user-input piped directly to execution functions
        'php_shell_passthru'       => '/\bpassthru\s*\(\s*\$_(GET|POST|REQUEST|COOKIE|SERVER)/i',
        'php_shell_exec'           => '/\bshell_exec\s*\(\s*\$_(GET|POST|REQUEST|COOKIE|SERVER)/i',
        'php_shell_system'         => '/\bsystem\s*\(\s*\$_(GET|POST|REQUEST|COOKIE|SERVER)/i',
        'php_shell_popen'          => '/\bpopen\s*\(\s*\$_(GET|POST|REQUEST|COOKIE|SERVER)/i',
        'php_shell_proc_open'      => '/\bproc_open\s*\([^)]*\$_(GET|POST|REQUEST|COOKIE)/i',
        'php_assert_shell'         => '/\bassert\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i',
        'php_eval_post'            => '/\beval\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i',
        'php_preg_replace_e'       => '/preg_replace\s*\(\s*[\'"].*\/e[\'"\s,]/i',

        // Base64 execution chains — the most common WordPress backdoor pattern
        'base64_exec_chain'        => '/\beval\s*\(\s*base64_decode\s*\(/i',
        'base64_gzinflate'         => '/\beval\s*\(\s*gzinflate\s*\(\s*base64_decode\s*\(/i',
        'base64_str_rot13'         => '/\beval\s*\(\s*str_rot13\s*\(\s*base64_decode\s*\(/i',
        'base64_gzuncompress'      => '/\beval\s*\(\s*gzuncompress\s*\(\s*base64_decode\s*\(/i',
        'base64_gzdecode'          => '/\beval\s*\(\s*gzdecode\s*\(\s*base64_decode\s*\(/i',
        'base64_rawurldecode'      => '/\beval\s*\(\s*rawurldecode\s*\(\s*base64_decode\s*\(/i',

        // Remote file inclusion
        'php_include_http'         => '/\b(?:include|require)(?:_once)?\s*\(\s*[\'"]https?:\/\//i',
        'php_include_post'         => '/\b(?:include|require)(?:_once)?\s*\(\s*\$_(GET|POST|REQUEST)/i',
        'php_fopen_http'           => '/\bfopen\s*\(\s*[\'"]https?:\/\//i',

        // PHP7/8 bypass — call_user_func with user input
        'call_user_func_post'      => '/\bcall_user_func(?:_array)?\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i',

        // Known backdoor signatures
        'php_create_function'      => '/\bcreate_function\s*\(\s*[\'"][^\'"]*[\'"]\s*,\s*\$_(GET|POST|REQUEST)/i',
        'c99_r57_signature'        => '/\$_COOKIE\s*\[.{0,50}eval\s*\(/i',
        'wso_shell_auth'           => '/\$auth_pass\s*=\s*[\'"][a-f0-9]{32}[\'"]/i',
        'alfa_shell_tag'           => '/AlfaShell|Alfa\s*Shell/i',
        'b374k_shell_tag'          => '/b374k|B374K/i',

        // PHP 8 named-arg bypass attempts
        'php8_bypass_named_arg'    => '/eval\s*\(\s*(?:base64_decode|gzinflate|str_rot13)\s*(?:\(\s*(?:base64_decode|gzinflate|str_rot13)\s*)+\(/i',

        // Dropper pattern — writes new PHP file
        'dropper_fwrite'           => '/\bfwrite\s*\(\s*\$\w+\s*,\s*(?:base64_decode|gzinflate|str_rot13)\s*\(/i',
        'dropper_file_put'         => '/\bfile_put_contents\s*\(\s*\$_(GET|POST|REQUEST|COOKIE|SERVER)/i',
    ];

    // ── Suspicious PHP patterns ───────────────────────────────────────────────

    /** Suspicious — warrant human review but not immediately certain threats. */
    private const SUSPICIOUS_PATTERNS = [
        // Obfuscation indicators
        'str_replace_obfuscate'    => '/str_replace\s*\(\s*[\'"][^\'"]{10,}[\'"]\s*,\s*[\'"][\'"]\s*,\s*[\'"][^\'"]{50,}[\'"]/i',
        'base64_long_string'       => '/base64_decode\s*\(\s*[\'"][A-Za-z0-9+\/]{200,}[\'"]\)/',
        'hex_obfuscation'          => '/\\\\x[0-9a-fA-F]{2}(?:\\\\x[0-9a-fA-F]{2}){10,}/',
        'chr_concat_obfuscate'     => '/(?:chr\s*\(\s*\d+\s*\)\s*\.?\s*){5,}/',
        'variable_variable_chain'  => '/\${\$[a-zA-Z_]\w*(?:\[.+?\])*}\s*\(/',  // ${$var}() pattern

        // Suspicious function use
        'eval_variable'            => '/\beval\s*\(\s*\$[a-zA-Z_]\w*\s*\)/i',
        'exec_hardcoded_cmd'       => '/\b(?:exec|shell_exec|passthru|popen)\s*\(\s*[\'"][^\'"]{5,}[\'"]/i',
        'chmod_777'                => '/\bchmod\s*\(\s*[\'"][^\'"]+[\'"]\s*,\s*0777\b/i',

        // Iframe injection
        'hidden_iframe'            => '/<iframe[^>]*(?:width\s*=\s*[\'"]?0[\'"]?|height\s*=\s*[\'"]?0[\'"]?|display\s*:\s*none)[^>]*>/i',
        'external_iframe'          => '/<iframe[^>]+src\s*=\s*[\'"]https?:\/\/(?!(?:www\.)?youtube|vimeo|google|maps\.google)[^\'"]+[\'"][^>]*>/i',

        // WordPress-specific backdoors
        'wp_debug_backdoor'        => '/wp_set_auth_cookie\s*\(\s*[0-9]+\s*,\s*true\s*\);\s*die/i',
        'wp_option_backdoor'       => '/update_option\s*\(\s*[\'"][^\'"]+[\'"]\s*,\s*\$_(GET|POST|REQUEST)/i',
        'wp_insert_user_shell'     => '/wp_create_user\s*\([^)]*\$_(GET|POST|REQUEST)/i',
        'wp_add_user_meta_shell'   => '/add_user_meta\s*\([^)]*\$_(GET|POST|REQUEST)/i',

        // cURL-based backdoors
        'curl_exec_post'           => '/curl_exec\s*\(\s*\$\w+\s*\);\s*(?:echo|print|eval)/i',
        'fsockopen_connect'        => '/\bfsockopen\s*\(\s*\$_(GET|POST|REQUEST|SERVER)/i',

        // Filesman / shell enumeration
        'dirlist_system'           => '/(?:scandir|glob)\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i',
    ];

    // ── JavaScript patterns ───────────────────────────────────────────────────

    private const JS_PATTERNS = [
        'js_document_write_eval'   => '/document\.write\s*\(\s*(?:unescape|atob|eval)\s*\(/i',
        'js_eval_obfuscated'       => '/eval\s*\(\s*(?:unescape|atob|String\.fromCharCode)\s*\(/i',
        'js_crypto_miner'          => '/(?:coinhive|coin-hive|cryptonight|minero\.pw|monero|stratum\+tcp|coinimp|webmr\.io)/i',
        'js_obfuscated_long'       => '/[\'"][A-Za-z0-9+\/]{500,}[\'"]\s*\)/',
        'js_fake_jquery'           => '/jQuery\.cookie\s*=\s*function\s*\(\s*\)\s*\{[^}]*eval\s*\(/i',
        'js_credit_card_skimmer'   => '/(?:cvv|cardnumber|cc_number|credit.card|expir).*(?:fetch|XMLHttpRequest|sendBeacon)\s*\(/i',
        'js_keylogger'             => '/addEventListener\s*\(\s*[\'"]keyup[\'"]\s*,[^)]+(?:fetch|XMLHttpRequest|sendBeacon)/i',
        'js_iframe_inject'         => '/document\.body\.innerHTML\s*\+=\s*[\'"]<iframe/i',
        'js_script_inject'         => '/document\.createElement\s*\(\s*[\'"]script[\'"]\s*\)[^;]*\.src\s*=\s*[\'"]https?:\/\//i',
        'js_formjack'              => '/document\.(querySelector|getElementById)\s*\([^)]*(?:form|password|login)[^)]*\)[^;]*addEventListener/i',
    ];

    // ── HTML injection patterns ───────────────────────────────────────────────

    private const HTML_PATTERNS = [
        'hidden_iframe'            => '/<iframe[^>]*(?:width\s*=\s*[\'"]?0[\'"]?|height\s*=\s*[\'"]?0[\'"]?|style\s*=\s*[\'"][^>\'"]*display\s*:\s*none)[^>]*>/i',
        'external_iframe'          => '/<iframe[^>]+src\s*=\s*[\'"]https?:\/\/(?!(?:www\.)?(?:youtube|vimeo|google|maps\.google|youtu\.be))[^\'"]+[\'"][^>]*>/i',
//        'meta_redirect_malicious'  => '/<meta[^>]+http-equiv\s*=\s*[\'"]refresh[\'"][^>]+url\s*=\s*https?:\/\/(?!(?:www\.)?(?:' . preg_quote( parse_url( home_url(), PHP_URL_HOST ) ?? 'localhost', '/' ) . '))/i',
//        'base_tag_hijack'          => '/<base[^>]+href\s*=\s*[\'"]https?:\/\/(?!(?:www\.)?(?:' . preg_quote( parse_url( home_url(), PHP_URL_HOST ) ?? 'localhost', '/' ) . '))/i',
    ];

    // ── Scannable file extensions ─────────────────────────────────────────────

    private const PHP_EXTENSIONS  = [ 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phps' ];
    private const JS_EXTENSIONS   = [ 'js', 'mjs' ];
    private const HTML_EXTENSIONS = [ 'html', 'htm', 'shtml', 'xhtml' ];

    private const ALL_SCANNABLE_EXTENSIONS = [
        ...self::PHP_EXTENSIONS,
        ...self::JS_EXTENSIONS,
        ...self::HTML_EXTENSIONS,
    ];

    /** Directories always excluded from scanning. */
    private const EXCLUDED_DIRS = [
        'node_modules', '.git', '.svn', 'vendor', 'bower_components',
        'ultraguard-quarantine', 'cache', '.cache',
    ];

    /**
     * Internal UltraGuard files that intentionally contain malware signatures,
     * heuristics, or cleaner patterns and must not be self-flagged.
     *
     * Keys are paths relative to ULTRAGUARD_PRO_PATH.
     *
     * @return array<string,string>
     */
    public static function get_internal_allowlisted_files(): array {
        $files = [
            'includes/modules/antivirus/class-antivirus-engine.php'   => __( 'Contains UltraGuard built-in heuristic patterns and malware signatures used by the scanner itself.', 'ultraguard-security-suite' ),
            'includes/modules/antivirus/class-signature-seeder.php'   => __( 'Contains bundled malware signature seed data shipped with UltraGuard.', 'ultraguard-security-suite' ),
            'includes/modules/antivirus/class-malware-cleaner.php'    => __( 'Contains malware cleanup and removal patterns that intentionally reference malicious code fragments.', 'ultraguard-security-suite' ),
            'includes/modules/filechecker/class-filechecker-engine.php' => __( 'Contains file integrity detection heuristics and suspicious code signatures used by UltraGuard FileChecker.', 'ultraguard-security-suite' ),
            'includes/modules/database_scanner/class-database-scanner-engine.php' => __( 'Contains malicious database payload patterns and cleanup signatures used by the Database Scanner.', 'ultraguard-security-suite' ),
            'includes/modules/database_scanner/class-database-scanner-module.php' => __( 'Contains built-in database threat pattern definitions used for spam, iframe, and eval-injection detection.', 'ultraguard-security-suite' ),
            'includes/modules/database_scanner/pages/class-database-scanner-dashboard.php' => __( 'Contains demo detection excerpts such as eval(base64_decode(...)) used to illustrate database threats in the UI.', 'ultraguard-security-suite' ),
            'includes/modules/woo_security/class-skimmer-detection-engine.php' => __( 'Contains checkout skimmer and data-exfiltration regex patterns used to detect malicious JavaScript on WooCommerce stores.', 'ultraguard-security-suite' ),
        ];

        return apply_filters( 'ultraguard_antivirus_internal_allowlist', $files );
    }

    // ── Instance state ────────────────────────────────────────────────────────

    private ?ScanResultsTable       $scan_table;
    private ?MalwareSignaturesTable $sig_table;
    private ?QuarantineTable        $quar_table;
    private array                   $settings;
    private string                  $scan_id;
    private int                     $files_scanned     = 0;
    private int                     $threats_found     = 0;
    private int                     $files_skipped     = 0;
    private array                   $wp_core_checksums = [];
    /** @var array<string,true> Tracks hashes already seen in this scan to skip duplicate inodes. */
    private array                   $scanned_hashes    = [];
    /**
     * Optional progress + cancellation callback.
     * Signature: fn(int $files_scanned, int $threats_found, string $current_file): bool
     * Returning false signals the engine to abort the scan.
     *
     * @var callable|null
     */
    private $progress_cb = null;
    /** Set to true when the progress callback returns false (cancellation signal). */
    private bool $cancelled = false;

    public function __construct(
        ?ScanResultsTable      $scan_table,
        MalwareSignaturesTable $sig_table,
        ?QuarantineTable       $quar_table,
        array                  $settings,
        string                 $scan_id
    ) {
        $this->scan_table = $scan_table;
        $this->sig_table  = $sig_table;
        $this->quar_table = $quar_table;
        $this->settings   = $settings;
        $this->scan_id    = $scan_id;
    }

    // ── Public entry points ───────────────────────────────────────────────────

    /**
     * Start a new chunked scan session and persist its initial progress state.
     */
    public function start_chunked_scan( array $options = [] ): string {
        $scan_type   = (string) ( $options['scan_type'] ?? 'quick' );
        $custom_path = (string) ( $options['custom_path'] ?? '' );
        $scan_id     = (string) ( $options['scan_id'] ?? $this->scan_id ?: 'scan_' . time() . '_' . wp_generate_password( 8, false ) );
        $dirs        = $this->resolve_chunk_scan_roots( $scan_type, $custom_path );
        $total_files = $this->count_scannable_files( $dirs );

        $this->scan_id = $scan_id;

        set_transient(
            self::PROGRESS_TRANSIENT,
            [
                'scan_id'       => $scan_id,
                'scan_type'     => $scan_type,
                'custom_path'   => $custom_path,
                'dirs'          => $dirs,
                'scanned'       => 0,
                'total'         => $total_files,
                'found'         => 0,
                'status'        => 'running',
                'started_at'    => current_time( 'mysql', true ),
                'current_file'  => '',
                'files_skipped' => 0,
            ],
            HOUR_IN_SECONDS
        );
        set_transient( self::RUNNING_TRANSIENT, $scan_id, HOUR_IN_SECONDS );
        set_transient( self::CURSOR_TRANSIENT, '', HOUR_IN_SECONDS );

        return $scan_id;
    }

    /**
     * Process one scan chunk and persist cursor/progress for later resumption.
     *
     * @return array{done:bool,progress:int,total:int,found:int,scan_id:string,status:string}
     */
    public function process_chunk(): array {
        $progress = get_transient( self::PROGRESS_TRANSIENT );
        if ( ! is_array( $progress ) || empty( $progress['scan_id'] ) ) {
            return [
                'done'     => true,
                'progress' => 0,
                'total'    => 0,
                'found'    => 0,
                'scan_id'  => '',
                'status'   => 'idle',
            ];
        }

        $this->scan_id        = (string) $progress['scan_id'];
        $this->files_scanned  = (int) ( $progress['scanned'] ?? 0 );
        $this->threats_found  = (int) ( $progress['found'] ?? 0 );
        $this->files_skipped  = (int) ( $progress['files_skipped'] ?? 0 );
        $cursor               = (string) get_transient( self::CURSOR_TRANSIENT );
        $processed            = 0;
        $last_cursor          = $cursor;
        $last_file            = '';

        foreach ( $this->iterate_chunk_files( (array) ( $progress['dirs'] ?? [] ), $cursor ) as $relative_path => $absolute_path ) {
            $last_cursor = (string) $relative_path;
            $last_file   = (string) $absolute_path;
            $this->scan_file( $absolute_path, (string) ( $progress['scan_type'] ?? 'quick' ) );
            $processed++;

            if ( $processed >= self::CHUNK_SIZE ) {
                break;
            }
        }

        $done = $processed < self::CHUNK_SIZE
            || (int) ( $progress['total'] ?? 0 ) <= $this->files_scanned;

        $progress['scanned']       = $this->files_scanned;
        $progress['found']         = $this->threats_found;
        $progress['files_skipped'] = $this->files_skipped;
        $progress['current_file']  = $last_file ? basename( $last_file ) : (string) ( $progress['current_file'] ?? '' );
        $progress['status']        = $done ? 'complete' : 'running';
        $progress['completed_at']  = $done ? current_time( 'mysql', true ) : null;

        set_transient( self::PROGRESS_TRANSIENT, $progress, $done ? DAY_IN_SECONDS : HOUR_IN_SECONDS );

        if ( $done ) {
            delete_transient( self::RUNNING_TRANSIENT );
            delete_transient( self::CURSOR_TRANSIENT );

            if ( ! empty( $this->settings['antivirus_auto_quarantine'] ) ) {
                $this->auto_quarantine_critical();
            }

            do_action( 'ultraguard_scan_complete', [
                'scan_id'       => $this->scan_id,
                'files_scanned' => $this->files_scanned,
                'threats_found' => $this->threats_found,
                'files_skipped' => $this->files_skipped,
            ] );
        } else {
            set_transient( self::CURSOR_TRANSIENT, $last_cursor, HOUR_IN_SECONDS );
        }

        return [
            'done'     => $done,
            'progress' => $this->files_scanned,
            'total'    => (int) ( $progress['total'] ?? 0 ),
            'found'    => $this->threats_found,
            'scan_id'  => $this->scan_id,
            'status'   => (string) $progress['status'],
        ];
    }

    /**
     * Return the current chunked scan progress payload.
     */
    public function get_progress(): array {
        $progress = get_transient( self::PROGRESS_TRANSIENT );

        return is_array( $progress ) ? $progress : [
            'scan_id'  => '',
            'status'   => 'idle',
            'scanned'  => 0,
            'total'    => 0,
            'found'    => 0,
            'progress' => 0,
        ];
    }

    /**
     * Cancel the current chunked scan session.
     */
    public function cancel_scan(): void {
        delete_transient( self::RUNNING_TRANSIENT );
        delete_transient( self::CURSOR_TRANSIENT );

        $progress = get_transient( self::PROGRESS_TRANSIENT );
        if ( is_array( $progress ) ) {
            $progress['status'] = 'cancelled';
            set_transient( self::PROGRESS_TRANSIENT, $progress, HOUR_IN_SECONDS );
        }
    }

    /**
     * Check whether a chunked scan session is currently running.
     */
    public function is_running(): bool {
        return (bool) get_transient( self::RUNNING_TRANSIENT );
    }

    /**
     * Register a progress / cancellation callback.
     * Called after every file is scanned. Return false to abort.
     *
     * @param callable $cb fn(int $files_scanned, int $threats_found, string $current_file): bool
     */
    public function set_progress_callback( callable $cb ): void {
        $this->progress_cb = $cb;
    }

    /**
     * Full scan — plugins, themes, wp-content, core.
     *
     * @return array{files_scanned: int, threats_found: int, files_skipped: int}
     */
    public function run_full_scan(): array {
        return $this->scan_dirs( $this->get_scan_dirs( 'full' ), 'full' );
    }

    /**
     * Quick scan — plugins and themes only.
     *
     * @return array{files_scanned: int, threats_found: int, files_skipped: int}
     */
    public function run_quick_scan(): array {
        return $this->scan_dirs( $this->get_scan_dirs( 'quick' ), 'quick' );
    }

    /**
     * Custom scan — a specific file or directory path.
     *
     * @param  string $path  Absolute path to file or directory.
     * @return array{files_scanned: int, threats_found: int, files_skipped: int}
     */
    public function run_custom_scan( string $path ): array {
        if ( ! $path ) { return $this->make_summary(); }

        // Validate the path is within ABSPATH to prevent traversal
        $real = realpath( $path );
        if ( ! $real || ! str_starts_with( $real, realpath( ABSPATH ) ) ) {
            return $this->make_summary();
        }

        if ( is_file( $real ) ) {
            $this->scan_file( $real );
        } elseif ( is_dir( $real ) ) {
            $this->scan_directory( $real, 'custom' );
        }

        return $this->make_summary();
    }

    /**
     * Scan a single file on-demand (synchronous, for UI requests).
     *
     * @param  string $path  Absolute file path.
     * @return array         Result row (same shape as scan_results DB record).
     */
    public function scan_single_file( string $path ): array {
        if ( ! file_exists( $path ) ) {
            return [ 'status' => 'error', 'message' => 'File not found' ];
        }

        $skip_reason = $this->get_scan_skip_reason( $path );
        if ( null !== $skip_reason ) {
            $is_internal_allowlist = null !== $this->get_internal_allowlist_reason( $path );

            return [
                'scan_id'        => $this->scan_id,
                'scan_type'      => 'single',
                'file_path'      => $path,
                'file_hash'      => md5_file( $path ) ?: null,
                'file_size'      => (int) @filesize( $path ),
                'threat_type'    => $is_internal_allowlist
                    ? __( 'Allowlisted Internal File', 'ultraguard-security-suite' )
                    : __( 'Excluded Path', 'ultraguard-security-suite' ),
                'threat_level'   => 'none',
                'status'         => 'skipped',
                'details'        => wp_json_encode( [ 'messages' => [ $skip_reason ] ] ),
                'skip_reason'    => $skip_reason,
                'is_allowlisted' => $is_internal_allowlist,
            ];
        }

        $row = $this->analyse_file( $path, 'custom' );
        if ( $row && $this->scan_table ) {
            $this->scan_table->insert( $row );
        }
        return $row ?? [ 'status' => 'clean' ];
    }

    // ── Vulnerability scan ────────────────────────────────────────────────────

    /**
     * Scan all active plugins, themes and WP core for known CVEs.
     *
     * @param  mixed $vuln_table  VulnerabilityScansTable instance.
     * @return array{scan_id: string, vulnerabilities_found: int}
     */
    public function run_vulnerability_scan( $vuln_table ): array {
        $vuln_scan_id = 'vscan_' . time();
        $found        = 0;

        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        // Plugins
        foreach ( get_plugins() as $plugin_file => $plugin_data ) {
            $slug    = dirname( $plugin_file );
            if ( $slug === '.' ) { $slug = basename( $plugin_file, '.php' ); }
            $version = $plugin_data['Version'] ?? '0.0.0';
            $found  += $this->insert_vulns( $vuln_table, $vuln_scan_id, $slug, $version, 'plugin' );
        }

        // Themes
        foreach ( wp_get_themes() as $slug => $theme ) {
            $version = $theme->get( 'Version' ) ?? '0.0.0';
            $found  += $this->insert_vulns( $vuln_table, $vuln_scan_id, (string) $slug, $version, 'theme' );
        }

        // WP Core
        global $wp_version;
        $found += $this->insert_vulns( $vuln_table, $vuln_scan_id, 'wordpress', $wp_version ?? '', 'core' );

        return [ 'scan_id' => $vuln_scan_id, 'vulnerabilities_found' => $found ];
    }

    // ── Private: directory and file iteration ─────────────────────────────────

    /**
     * @param  string[] $dirs
     * @return array{files_scanned: int, threats_found: int, files_skipped: int}
     */
    private function scan_dirs( array $dirs, string $scan_type ): array {
        foreach ( $dirs as $dir ) {
            if ( is_dir( $dir ) ) {
                $this->scan_directory( $dir, $scan_type );
            }
        }
        if ( ! empty( $this->settings['antivirus_auto_quarantine'] ) ) {
            $this->auto_quarantine_critical();
        }
        return $this->make_summary();
    }

    /**
     * Recursively scan a directory, skipping excluded subdirectories.
     *
     * @param string $dir        Absolute directory path.
     * @param string $scan_type  Scan type label.
     */
    private function scan_directory( string $dir, string $scan_type = 'full' ): void {
        try {
            /*
             * Use RecursiveCallbackFilterIterator so entire subtrees of excluded
             * directories are properly skipped — a simple $it->next() in SELF_FIRST
             * mode only skips one entry, not the full subtree.
             */
            $base_it = new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS );
            $filter  = new \RecursiveCallbackFilterIterator(
                $base_it,
                function ( \SplFileInfo $file, $key, \RecursiveDirectoryIterator $inner ) {
                    // For directories: decide whether to descend into them
                    if ( $inner->hasChildren() ) {
                        if ( in_array( $file->getFilename(), self::EXCLUDED_DIRS, true ) ) {
                            return false;
                        }
                        if ( $this->is_excluded_path( $file->getPathname() ) ) {
                            return false;
                        }
                    }
                    return true;
                }
            );
            $it = new \RecursiveIteratorIterator( $filter, \RecursiveIteratorIterator::LEAVES_ONLY );

            foreach ( $it as $file ) {
                // Respect cancellation from the progress callback
                if ( $this->cancelled ) { break; }

                if ( ! $file->isFile() || ! $file->isReadable() ) { continue; }
                $ext = strtolower( $file->getExtension() );
                if ( ! in_array( $ext, $this->get_scannable_extensions(), true ) ) { continue; }
                $this->scan_file( $file->getPathname(), $scan_type );
            }
        } catch ( \Exception $e ) {
            if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                \UltraguardPro\core\Logger::error( 'UltraGuard AntivirusEngine scan_directory error: ' . $e->getMessage() );
            }
        }
    }

    /**
     * Analyse a single file and insert a result row if it needs recording.
     *
     * @param string $path       Absolute file path.
     * @param string $scan_type  Scan type label.
     */
    private function scan_file( string $path, string $scan_type = 'custom' ): void {
        $skip_reason = $this->get_scan_skip_reason( $path );
        if ( null !== $skip_reason ) {
            $this->files_skipped++;
            return;
        }

        $max_bytes = (int) ( $this->settings['antivirus_max_file_size_mb'] ?? 10 ) * 1024 * 1024;
        if ( @filesize( $path ) > $max_bytes ) {
            $this->files_skipped++;
            return;
        }

        $hash = md5_file( $path );
        if ( ! $hash ) {
            $this->files_skipped++;
            return;
        }

        if ( $this->scan_table && $this->scan_table->is_known_false_positive( $path, $hash ) ) {
            $this->files_skipped++;
            return;
        }

        // Skip duplicate inodes within a single scan run
        if ( isset( $this->scanned_hashes[ $hash ] ) ) {
            $this->files_skipped++;
            return;
        }
        $this->scanned_hashes[ $hash ] = true;

        $row = $this->analyse_file( $path, $scan_type, $hash );
        if ( $row && $this->scan_table ) {
            $this->scan_table->insert( $row );
            if ( ( $row['status'] ?? 'clean' ) !== 'clean' ) {
                $this->threats_found++;
            }
        }
        $this->files_scanned++;

        // Fire progress callback and respect cancellation signal
        if ( null !== $this->progress_cb ) {
            $continue = ( $this->progress_cb )( $this->files_scanned, $this->threats_found, $path );
            if ( $continue === false ) {
                $this->cancelled = true;
            }
        }
    }

    /**
     * Perform all detection checks on a single file.
     *
     * @param  string $path       Absolute file path.
     * @param  string $scan_type  Scan type label.
     * @param  string $hash       Pre-computed MD5 hash (optional).
     * @return array<string,mixed>|null  Result record or null on I/O error.
     */
    private function analyse_file( string $path, string $scan_type = 'custom', string $hash = '' ): ?array {
        if ( ! $hash ) { $hash = md5_file( $path ); }
        if ( ! $hash ) { return null; }

        $size = (int) @filesize( $path );
        $ext  = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );

        $threat_type  = null;
        $threat_level = 'none';
        $status       = 'clean';
        $details      = [];
        $content      = null;

        // ── Layer 1: Hash matching ────────────────────────────────────────────
        $sig = $this->sig_table->find_by_hash( $hash );
        if ( $sig ) {
            $threat_type  = $sig['name'];
            $threat_level = $sig['threat_level'];
            $status       = 'infected';
            $details[]    = 'Signature match: ' . $sig['name'];
            $this->sig_table->increment_hits( (int) $sig['id'] );
        }

        // ── Content analysis (only for text files within size limit) ──────────
        if ( $status === 'clean' ) {
            $content = @file_get_contents( $path );
            if ( $content !== false ) {
                // ── Layer 2 & 3: PHP pattern analysis ────────────────────────
                if ( in_array( $ext, self::PHP_EXTENSIONS, true )
                    && ! empty( $this->settings['antivirus_scan_php'] ) ) {

                    [ $t, $l, $d ] = $this->run_php_patterns( $content );
                    if ( $t ) { $threat_type = $t; $threat_level = $l; $status = 'infected'; $details = array_merge( $details, $d ); }
                }

                // ── Layer 4: Entropy check for PHP ────────────────────────────
                if ( $status === 'clean' && in_array( $ext, self::PHP_EXTENSIONS, true ) ) {
                    [ $t, $l, $d ] = $this->run_entropy_check( $content );
                    if ( $t ) { $threat_type = $t; $threat_level = $l; $status = 'infected'; $details = array_merge( $details, $d ); }
                }

                // ── Layer 5: JavaScript ───────────────────────────────────────
                if ( $status === 'clean'
                    && in_array( $ext, self::JS_EXTENSIONS, true )
                    && ! empty( $this->settings['antivirus_scan_js'] ) ) {

                    [ $t, $l, $d ] = $this->run_js_patterns( $content );
                    if ( $t ) { $threat_type = $t; $threat_level = $l; $status = 'infected'; $details = array_merge( $details, $d ); }
                }

                // ── Layer 6: HTML injection ───────────────────────────────────
                if ( $status === 'clean'
                    && in_array( $ext, self::HTML_EXTENSIONS, true )
                    && ! empty( $this->settings['antivirus_scan_html'] ) ) {

                    [ $t, $l, $d ] = $this->run_html_patterns( $content );
                    if ( $t ) { $threat_type = $t; $threat_level = $l; $status = 'infected'; $details = array_merge( $details, $d ); }
                }
            }
        }

        // ── Layer 7: WordPress core integrity ─────────────────────────────────
        if ( $status === 'clean' && in_array( $ext, self::PHP_EXTENSIONS, true ) && $this->is_wp_core_file( $path ) ) {
            if ( ! $this->verify_core_checksum( $path, $hash ) ) {
                $threat_type  = 'WordPress Core File Modified';
                $threat_level = 'high';
                $status       = 'infected';
                $details[]    = 'File hash does not match official WordPress checksum';
            }
        }

        $details_payload = $details
            ? self::build_detection_details_payload(
                $details,
                $path,
                $threat_type,
                is_string( $content ) ? $content : null,
                $size
            )
            : null;

        return [
            'scan_id'      => $this->scan_id,
            'scan_type'    => $scan_type,
            'file_path'    => $path,
            'file_hash'    => $hash,
            'file_size'    => $size,
            'threat_type'  => $threat_type,
            'threat_level' => $threat_level,
            'status'       => $status,
            'details'      => $details_payload ? wp_json_encode( $details_payload ) : null,
            'created_by'   => get_current_user_id() ?: null,
        ];
    }

    /**
     * Normalize stored scan-result details for UI rendering.
     *
     * @param  array<string,mixed> $result
     * @return array{messages: string[], details_text: string, matched_code: string, match_context: string, match_label: string}
     */
    public static function get_detection_metadata( array $result ): array {
        $messages      = [];
        $matched_code  = '';
        $match_context = '';
        $match_label   = '';
        $raw_details   = (string) ( $result['details'] ?? '' );

        if ( '' !== $raw_details ) {
            $decoded = json_decode( $raw_details, true );

            if ( is_array( $decoded ) ) {
                if ( isset( $decoded['messages'] ) && is_array( $decoded['messages'] ) ) {
                    $messages = array_values(
                        array_filter(
                            array_map(
                                static fn( $message ): string => is_scalar( $message ) ? trim( (string) $message ) : '',
                                $decoded['messages']
                            ),
                            static fn( string $message ): bool => '' !== $message
                        )
                    );
                } else {
                    $messages = array_values(
                        array_filter(
                            array_map(
                                static fn( $message ): string => is_scalar( $message ) ? trim( (string) $message ) : '',
                                $decoded
                            ),
                            static fn( string $message ): bool => '' !== $message
                        )
                    );
                }

                $matched_code  = sanitize_textarea_field( (string) ( $decoded['matched_code'] ?? '' ) );
                $match_context = sanitize_textarea_field( (string) ( $decoded['match_context'] ?? '' ) );
                $match_label   = sanitize_text_field( (string) ( $decoded['match_label'] ?? '' ) );
            } else {
                $messages = [ $raw_details ];
            }
        }

        if ( '' === $matched_code && '' === $match_context ) {
            $live_match = self::extract_detection_match(
                (string) ( $result['file_path'] ?? '' ),
                (string) ( $result['threat_type'] ?? '' ),
                isset( $result['file_size'] ) ? (int) $result['file_size'] : null
            );

            if ( '' !== $live_match['matched_code'] ) {
                $matched_code = $live_match['matched_code'];
            }
            if ( '' !== $live_match['match_context'] ) {
                $match_context = $live_match['match_context'];
            }
            if ( '' !== $live_match['match_label'] ) {
                $match_label = $live_match['match_label'];
            }
        }

        return [
            'messages'      => $messages,
            'details_text'  => $messages ? implode( "\n", $messages ) : '—',
            'matched_code'  => $matched_code,
            'match_context' => $match_context,
            'match_label'   => $match_label,
        ];
    }

    /**
     * Build structured details payload for storage.
     *
     * @param  string[]     $messages
     * @param  string       $file_path
     * @param  string|null  $threat_type
     * @param  string|null  $content
     * @param  int|null     $file_size
     * @return array<string,mixed>
     */
    private static function build_detection_details_payload(
        array $messages,
        string $file_path,
        ?string $threat_type,
        ?string $content = null,
        ?int $file_size = null
    ): array {
        $payload = [
            'messages' => array_values(
                array_filter(
                    array_map(
                        static fn( $message ): string => trim( (string) $message ),
                        $messages
                    ),
                    static fn( string $message ): bool => '' !== $message
                )
            ),
        ];

        $match = self::extract_detection_match( $file_path, (string) $threat_type, $file_size, $content );
        if ( '' !== $match['matched_code'] ) {
            $payload['matched_code'] = $match['matched_code'];
        }
        if ( '' !== $match['match_context'] ) {
            $payload['match_context'] = $match['match_context'];
        }
        if ( '' !== $match['match_label'] ) {
            $payload['match_label'] = $match['match_label'];
        }

        return $payload;
    }

    /**
     * Extract the exact matched code and nearby context for a detection.
     *
     * @param  string       $file_path
     * @param  string       $threat_type
     * @param  int|null     $file_size
     * @param  string|null  $content
     * @return array{matched_code: string, match_context: string, match_label: string}
     */
    private static function extract_detection_match(
        string $file_path,
        string $threat_type,
        ?int $file_size = null,
        ?string $content = null
    ): array {
        if ( null === $content ) {
            if ( '' === $file_path || ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
                return [
                    'matched_code'  => '',
                    'match_context' => '',
                    'match_label'   => '',
                ];
            }

            $resolved_size = null !== $file_size ? $file_size : (int) @filesize( $file_path );
            if ( $resolved_size > self::MAX_MATCH_FILESIZE ) {
                return [
                    'matched_code'  => '',
                    'match_context' => '',
                    'match_label'   => __( 'Match extraction skipped for large file', 'ultraguard-security-suite' ),
                ];
            }

            $content = @file_get_contents( $file_path );
            if ( false === $content ) {
                return [
                    'matched_code'  => '',
                    'match_context' => '',
                    'match_label'   => '',
                ];
            }
        }

        $threat_type = trim( $threat_type );

        if ( isset( self::CRITICAL_PATTERNS[ $threat_type ] ) ) {
            return self::extract_regex_match( $content, self::CRITICAL_PATTERNS[ $threat_type ], __( 'Critical code match', 'ultraguard-security-suite' ) );
        }
        if ( isset( self::SUSPICIOUS_PATTERNS[ $threat_type ] ) ) {
            return self::extract_regex_match( $content, self::SUSPICIOUS_PATTERNS[ $threat_type ], __( 'Suspicious code match', 'ultraguard-security-suite' ) );
        }
        if ( isset( self::JS_PATTERNS[ $threat_type ] ) ) {
            return self::extract_regex_match( $content, self::JS_PATTERNS[ $threat_type ], __( 'JavaScript code match', 'ultraguard-security-suite' ) );
        }
        if ( isset( self::HTML_PATTERNS[ $threat_type ] ) ) {
            return self::extract_regex_match( $content, self::HTML_PATTERNS[ $threat_type ], __( 'HTML injection match', 'ultraguard-security-suite' ) );
        }
        if ( 'high_entropy_string' === $threat_type ) {
            return self::extract_entropy_match( $content );
        }
        if ( 'WordPress Core File Modified' === $threat_type ) {
            return [
                'matched_code'  => '',
                'match_context' => '',
                'match_label'   => __( 'Whole-file checksum mismatch detected; no single pattern match is available.', 'ultraguard-security-suite' ),
            ];
        }

        return [
            'matched_code'  => '',
            'match_context' => '',
            'match_label'   => __( 'Detection triggered by a stored signature or heuristic summary.', 'ultraguard-security-suite' ),
        ];
    }

    /**
     * Extract a regex match and a small surrounding code context.
     *
     * @param  string $content
     * @param  string $pattern
     * @param  string $label
     * @return array{matched_code: string, match_context: string, match_label: string}
     */
    private static function extract_regex_match( string $content, string $pattern, string $label ): array {
        $matched_code = '';
        $offset       = 0;

        if ( ! @preg_match( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) {
            return [
                'matched_code'  => '',
                'match_context' => '',
                'match_label'   => $label,
            ];
        }

        if ( isset( $matches[0][0] ) && is_string( $matches[0][0] ) ) {
            $matched_code = $matches[0][0];
        }
        if ( isset( $matches[0][1] ) ) {
            $offset = (int) $matches[0][1];
        }

        return [
            'matched_code'  => self::normalize_code_snippet( $matched_code ),
            'match_context' => self::build_context_snippet( $content, $offset, strlen( $matched_code ) ),
            'match_label'   => $label,
        ];
    }

    /**
     * Extract the exact long encoded string that triggered entropy detection.
     *
     * @param  string $content
     * @return array{matched_code: string, match_context: string, match_label: string}
     */
    private static function extract_entropy_match( string $content ): array {
        if ( ! preg_match_all( '/[\'"]([A-Za-z0-9+\/=]{100,})[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE ) ) {
            return [
                'matched_code'  => '',
                'match_context' => '',
                'match_label'   => __( 'High-entropy encoded string', 'ultraguard-security-suite' ),
            ];
        }

        foreach ( $matches[1] as $candidate ) {
            $value  = (string) ( $candidate[0] ?? '' );
            $offset = (int) ( $candidate[1] ?? 0 );

            if ( '' === $value ) {
                continue;
            }

            if ( self::shannon_entropy_static( $value ) > 5.2 ) {
                return [
                    'matched_code'  => self::normalize_code_snippet( $value ),
                    'match_context' => self::build_context_snippet( $content, $offset, strlen( $value ) ),
                    'match_label'   => __( 'High-entropy encoded string', 'ultraguard-security-suite' ),
                ];
            }
        }

        return [
            'matched_code'  => '',
            'match_context' => '',
            'match_label'   => __( 'High-entropy encoded string', 'ultraguard-security-suite' ),
        ];
    }

    /**
     * Build a readable code excerpt around a match.
     *
     * @param  string $content
     * @param  int    $offset
     * @param  int    $length
     * @return string
     */
    private static function build_context_snippet( string $content, int $offset, int $length ): string {
        if ( $length <= 0 ) {
            return '';
        }

        $radius = 140;
        $start  = max( 0, $offset - $radius );
        $before = $start > 0 ? "...\n" : '';
        $slice  = substr( $content, $start, $length + ( $radius * 2 ) );
        $after  = ( $start + strlen( (string) $slice ) ) < strlen( $content ) ? "\n..." : '';

        return self::normalize_code_snippet( $before . (string) $slice . $after );
    }

    /**
     * Normalize snippets for textarea display.
     *
     * @param  string $snippet
     * @return string
     */
    private static function normalize_code_snippet( string $snippet ): string {
        $snippet = str_replace( [ "\r\n", "\r" ], "\n", $snippet );
        return trim( $snippet );
    }

    /**
     * Static Shannon entropy calculator for match reconstruction.
     *
     * @param  string $str
     * @return float
     */
    private static function shannon_entropy_static( string $str ): float {
        $len = strlen( $str );
        if ( 0 === $len ) {
            return 0.0;
        }

        $freq = array_count_values( str_split( $str ) );
        $h    = 0.0;
        foreach ( $freq as $count ) {
            $p  = $count / $len;
            $h -= $p * log( $p, 2 );
        }

        return $h;
    }

    // ── Pattern runners ───────────────────────────────────────────────────────

    /**
     * Run PHP critical and suspicious patterns against file content.
     *
     * @return array{0: string|null, 1: string, 2: string[]}
     */
    private function run_php_patterns( string $content ): array {
        foreach ( self::CRITICAL_PATTERNS as $name => $pattern ) {
            if ( @preg_match( $pattern, $content ) ) {
                return [ $name, 'critical', [ 'Critical pattern: ' . $name ] ];
            }
        }
        foreach ( self::SUSPICIOUS_PATTERNS as $name => $pattern ) {
            if ( @preg_match( $pattern, $content ) ) {
                return [ $name, 'high', [ 'Suspicious pattern: ' . $name ] ];
            }
        }
        return [ null, 'none', [] ];
    }

    /**
     * Run JavaScript malicious patterns.
     *
     * @return array{0: string|null, 1: string, 2: string[]}
     */
    private function run_js_patterns( string $content ): array {
        foreach ( self::JS_PATTERNS as $name => $pattern ) {
            if ( @preg_match( $pattern, $content ) ) {
                return [ $name, 'high', [ 'JS malicious pattern: ' . $name ] ];
            }
        }
        return [ null, 'none', [] ];
    }

    /**
     * Run HTML injection patterns.
     *
     * @return array{0: string|null, 1: string, 2: string[]}
     */
    private function run_html_patterns( string $content ): array {
        foreach ( self::HTML_PATTERNS as $name => $pattern ) {
            if ( @preg_match( $pattern, $content ) ) {
                return [ $name, 'medium', [ 'HTML injection: ' . $name ] ];
            }
        }
        return [ null, 'none', [] ];
    }

    /**
     * Entropy-based detection — high-entropy strings are typical of base64-encoded payloads.
     * Threshold calibrated to minimise false positives in normal PHP/JS.
     *
     * @return array{0: string|null, 1: string, 2: string[]}
     */
    private function run_entropy_check( string $content ): array {
        // Only inspect long quoted strings
        if ( ! preg_match_all( '/[\'"]([A-Za-z0-9+\/=]{100,})[\'"]/', $content, $matches ) ) {
            return [ null, 'none', [] ];
        }
        foreach ( $matches[1] as $candidate ) {
            $entropy = $this->shannon_entropy( $candidate );
            if ( $entropy > 5.2 ) {
                return [
                    'high_entropy_string',
                    'medium',
                    [ sprintf( 'High-entropy encoded string detected (entropy=%.2f)', $entropy ) ],
                ];
            }
        }
        return [ null, 'none', [] ];
    }

    /**
     * Calculate Shannon entropy for a string.
     *
     * @param  string $str  Input string.
     * @return float        Entropy value (bits per character, max ≈ 5.95 for base64).
     */
    private function shannon_entropy( string $str ): float {
        $len = strlen( $str );
        if ( $len === 0 ) { return 0.0; }
        $freq = array_count_values( str_split( $str ) );
        $h    = 0.0;
        foreach ( $freq as $count ) {
            $p  = $count / $len;
            $h -= $p * log( $p, 2 );
        }
        return $h;
    }

    // ── WP core integrity ─────────────────────────────────────────────────────

    /**
     * Determine whether a file path is a tracked WordPress core file.
     *
     * @param  string $path  Absolute file path.
     * @return bool
     */
    private function is_wp_core_file( string $path ): bool {
        $rel = ltrim( str_replace( ABSPATH, '', $path ), '/' );
        return (
            str_starts_with( $rel, 'wp-includes/' ) ||
            str_starts_with( $rel, 'wp-admin/' ) ||
            in_array( $rel, [
                'wp-login.php', 'wp-cron.php', 'wp-load.php',
                'wp-settings.php', 'wp-config-sample.php', 'wp-blog-header.php',
            ], true )
        );
    }

    /**
     * Verify a core file's MD5 against the official WordPress checksum API.
     *
     * @param  string $path  Absolute file path.
     * @param  string $hash  MD5 hash of the file.
     * @return bool          True = matches official checksum (or cannot verify).
     */
    private function verify_core_checksum( string $path, string $hash ): bool {
        if ( empty( $this->wp_core_checksums ) ) {
            $this->load_wp_checksums();
        }
        if ( empty( $this->wp_core_checksums ) ) { return true; } // Cannot verify

        $rel = ltrim( str_replace( ABSPATH, '', $path ), '/' );
        if ( ! isset( $this->wp_core_checksums[ $rel ] ) ) { return true; } // Not a tracked file

        return $this->wp_core_checksums[ $rel ] === $hash;
    }

    /**
     * Fetch WordPress core checksums from the official API or local transient cache.
     */
    private function load_wp_checksums(): void {
        global $wp_version;
        $locale    = get_locale();
        $cache_key = 'ultraguard_wp_checksums_' . $wp_version . '_' . $locale;
        $cached    = get_transient( $cache_key );

        if ( is_array( $cached ) ) {
            $this->wp_core_checksums = $cached;
            return;
        }

        $url  = 'https://api.wordpress.org/core/checksums/1.0/?version=' . urlencode( (string) $wp_version ) . '&locale=' . urlencode( $locale );
        $resp = SecureRemoteTransport::request_json( 'GET', $url, [ 'timeout' => 15, 'sslverify' => true ] );

        if ( ! $resp['success'] ) { return; }

        $data = is_array( $resp['body'] ?? null ) ? $resp['body'] : [];
        if ( ! empty( $data['checksums'] ) && is_array( $data['checksums'] ) ) {
            $this->wp_core_checksums = $data['checksums'];
            set_transient( $cache_key, $data['checksums'], 12 * HOUR_IN_SECONDS );
        }
    }

    // ── Vulnerability checking ────────────────────────────────────────────────

    /**
     * Insert vulnerability records for a component into the DB.
     *
     * @param  mixed  $vuln_table    VulnerabilityScansTable instance.
     * @param  string $vuln_scan_id  Current vulnerability scan session ID.
     * @param  string $slug          Component slug.
     * @param  string $version       Installed version.
     * @param  string $type          'plugin' | 'theme' | 'core'.
     * @return int                   Number of vulnerabilities inserted.
     */
    private function insert_vulns( $vuln_table, string $vuln_scan_id, string $slug, string $version, string $type ): int {
        $vulns = $this->check_component_vulnerabilities( $slug, $version, $type );
        $count = 0;
        foreach ( $vulns as $vuln ) {
            $vuln_table->insert( array_merge( $vuln, [
                'scan_id'           => $vuln_scan_id,
                'component_type'    => $type,
                'component_slug'    => $slug,
                'component_version' => $version,
            ] ) );
            $count++;
        }
        return $count;
    }

    /**
     * Check a component against local vulnerability signatures and optionally the WPVulnDB API.
     *
     * @param  string $slug     Component slug.
     * @param  string $version  Installed version.
     * @param  string $type     'plugin' | 'theme' | 'core'.
     * @return array<int,array<string,mixed>>  Array of vulnerability records.
     */
    private function check_component_vulnerabilities( string $slug, string $version, string $type ): array {
        $vulns = [];

        // Check local signatures for vulnerability-type entries
        $local = $this->sig_table->get_by( [ 'type' => 'pattern', 'source' => 'vulnerability' ], 5000 );
        foreach ( $local as $sig ) {
            if ( empty( $sig['pattern'] ) ) { continue; }
            $data = json_decode( $sig['pattern'], true );
            if ( ! is_array( $data ) ) { continue; }
            if ( ( $data['component_slug'] ?? '' ) !== $slug ) { continue; }
            if ( ! empty( $data['fixed_in'] ) && version_compare( $version, $data['fixed_in'], '>=' ) ) { continue; }
            $vulns[] = [
                'vulnerability_type' => $sig['name'],
                'severity'           => $sig['threat_level'],
                'description'        => $data['description'] ?? $sig['name'],
                'cve_id'             => $data['cve_id'] ?? null,
                'remediation'        => ! empty( $data['fixed_in'] )
                    ? 'Update to version ' . $data['fixed_in'] . ' or later.'
                    : 'Check for available updates.',
                'status'             => 'open',
            ];
        }

        // Optional: live WPVulnDB/WPScan API call
        $api_key = $this->settings['antivirus_wpvulndb_api_key'] ?? '';
        if ( $api_key && in_array( $type, [ 'plugin', 'theme' ], true ) ) {
            $api_vulns = $this->fetch_wpvulndb_vulns( $slug, $version, $type, $api_key );
            $vulns     = array_merge( $vulns, $api_vulns );
        }

        return $vulns;
    }

    /**
     * Fetch live vulnerability data from WPVulnDB/WPScan API.
     *
     * @param  string $slug     Plugin/theme slug.
     * @param  string $version  Installed version.
     * @param  string $type     'plugin' | 'theme'.
     * @param  string $api_key  WPVulnDB API token.
     * @return array<int,array<string,mixed>>
     */
    private function fetch_wpvulndb_vulns( string $slug, string $version, string $type, string $api_key ): array {
        $cache_key = 'ultraguard_wpvulndb_' . md5( $slug . $version . $type );
        $cached    = get_transient( $cache_key );
        if ( $cached !== false ) { return is_array( $cached ) ? $cached : []; }

        $endpoint = 'https://www.wpvulndb.com/api/v3/' . ( $type === 'plugin' ? 'plugins' : 'themes' ) . '/' . urlencode( $slug );
        $resp = SecureRemoteTransport::request_json( 'GET', $endpoint, [
            'timeout'   => 10,
            'sslverify' => true,
            'headers'   => [ 'Authorization' => 'Token token=' . $api_key ],
        ] );

        if ( ! $resp['success'] ) {
            set_transient( $cache_key, [], HOUR_IN_SECONDS );
            return [];
        }

        $data = is_array( $resp['body'] ?? null ) ? $resp['body'] : [];
        if ( empty( $data[ $slug ]['vulnerabilities'] ) ) {
            set_transient( $cache_key, [], HOUR_IN_SECONDS );
            return [];
        }

        $vulns = [];
        foreach ( $data[ $slug ]['vulnerabilities'] as $v ) {
            $fixed_in = $v['fixed_in'] ?? null;
            if ( $fixed_in && version_compare( $version, $fixed_in, '>=' ) ) { continue; }

            $vulns[] = [
                'vulnerability_type' => $v['title'] ?? 'Unknown',
                'severity'           => $this->map_cvss_to_severity( $v['cvss'] ?? null ),
                'description'        => $v['title'] ?? '',
                'cve_id'             => ! empty( $v['cve'] ) ? $v['cve'] : null,
                'remediation'        => $fixed_in
                    ? 'Update to version ' . $fixed_in . ' or later.'
                    : 'No patch available — consider deactivating this component.',
                'status'             => 'open',
            ];
        }

        set_transient( $cache_key, $vulns, 6 * HOUR_IN_SECONDS );
        return $vulns;
    }

    /**
     * Map CVSS numeric score to a severity label.
     *
     * @param  mixed $cvss  CVSS score (float string or null).
     * @return string       'critical' | 'high' | 'medium' | 'low'
     */
    private function map_cvss_to_severity( $cvss ): string {
        $score = (float) ( $cvss ?? 0 );
        if ( $score >= 9.0 ) { return 'critical'; }
        if ( $score >= 7.0 ) { return 'high'; }
        if ( $score >= 4.0 ) { return 'medium'; }
        return 'low';
    }

    // ── Auto-quarantine ───────────────────────────────────────────────────────

    /**
     * Auto-quarantine all critical-severity threats found in this scan session.
     */
    private function auto_quarantine_critical(): void {
        if ( ! $this->scan_table || ! $this->quar_table ) { return; }

        $critical = $this->scan_table->get_by( [
            'scan_id'      => $this->scan_id,
            'threat_level' => 'critical',
            'status'       => 'infected',
        ], 100 );

        foreach ( $critical as $result ) {
            $this->quarantine_result( $result );
        }
    }

    /**
     * Move a scan result's file to the quarantine directory.
     *
     * @param  array<string,mixed> $result  Scan result row.
     * @return bool                         True on success.
     */
    private function quarantine_result( array $result ): bool {
        $upload_dir     = wp_upload_dir();
        $quarantine_dir = trailingslashit( $upload_dir['basedir'] ) . 'ultraguard-quarantine/';
        wp_mkdir_p( $quarantine_dir );

        $htaccess = $quarantine_dir . '.htaccess';
        if ( ! file_exists( $htaccess ) ) {
            file_put_contents( $htaccess, "Deny from all\nOptions -Indexes\n" );
        }

        $dest = $quarantine_dir . basename( $result['file_path'] ) . '.' . time() . '.quarantine';

        if ( ! file_exists( $result['file_path'] ) ) { return false; }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic move into quarantine within the local filesystem.
        if ( ! @rename( $result['file_path'], $dest ) ) { return false; }

        $this->quar_table->insert( [
            'original_path'   => $result['file_path'],
            'quarantine_path' => $dest,
            'file_hash'       => $result['file_hash']    ?? '',
            'file_size'       => (int) ( $result['file_size'] ?? 0 ),
            'threat_type'     => $result['threat_type']  ?? '',
            'threat_level'    => $result['threat_level'] ?? 'critical',
        ] );

        if ( isset( $result['id'] ) && $this->scan_table ) {
            $this->scan_table->update( (int) $result['id'], [ 'status' => 'quarantined' ] );
        }

        do_action( 'ultraguard_antivirus_file_quarantined', $result['file_path'], $dest );
        return true;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /**
     * Build the list of directories to scan based on scan type.
     *
     * @param  string   $type  'quick' | 'full'.
     * @return string[]
     */
    private function get_scan_dirs( string $type ): array {
        $dirs = [
            WP_CONTENT_DIR . '/plugins',
            WP_CONTENT_DIR . '/themes',
        ];

        if ( $type === 'full' ) {
            $dirs = array_merge( $dirs, [
                ABSPATH . 'wp-includes',
                ABSPATH . 'wp-admin',
            ] );
            // Scan uploads only if explicitly enabled
            if ( ! empty( $this->settings['antivirus_scan_uploads'] ) ) {
                $dirs[] = WP_CONTENT_DIR . '/uploads';
            }
        }

        return apply_filters( 'ultraguard_antivirus_scan_dirs', $dirs, $type );
    }

    /**
     * Resolve the scan roots used by the chunked scanner.
     *
     * @return string[]
     */
    private function resolve_chunk_scan_roots( string $scan_type, string $custom_path = '' ): array {
        if ( 'custom' === $scan_type ) {
            $real = $custom_path ? realpath( $custom_path ) : false;

            if ( ! $real ) {
                return [];
            }

            $base = realpath( ABSPATH );
            if ( ! $base || ! str_starts_with( $real, $base ) ) {
                return [];
            }

            return [ $real ];
        }

        return $this->get_scan_dirs( $scan_type );
    }

    /**
     * Count scannable files without storing the file list in memory.
     */
    private function count_scannable_files( array $dirs ): int {
        $count = 0;

        foreach ( $this->iterate_chunk_files( $dirs ) as $relative_path => $absolute_path ) {
            $count++;
        }

        return $count;
    }

    /**
     * Yield scannable files in a deterministic order suitable for cursor resumption.
     *
     * @return \Generator<string,string>
     */
    private function iterate_chunk_files( array $dirs, string $cursor = '' ): \Generator {
        natcasesort( $dirs );

        foreach ( $dirs as $dir ) {
            $real = realpath( $dir );
            if ( ! $real ) {
                continue;
            }

            yield from $this->iterate_chunk_path( $real, $cursor );
        }
    }

    /**
     * Recursively yield sorted files for a directory or a single file root.
     *
     * @return \Generator<string,string>
     */
    private function iterate_chunk_path( string $path, string $cursor = '' ): \Generator {
        if ( is_file( $path ) ) {
            $relative = $this->build_chunk_cursor( $path );
            if ( $this->is_chunk_candidate( $path, $relative, $cursor ) ) {
                yield $relative => $path;
            }
            return;
        }

        if ( ! is_dir( $path ) ) {
            return;
        }

        $entries = scandir( $path );
        if ( ! is_array( $entries ) ) {
            return;
        }

        natcasesort( $entries );

        foreach ( $entries as $entry ) {
            if ( '.' === $entry || '..' === $entry ) {
                continue;
            }

            $full_path = $path . DIRECTORY_SEPARATOR . $entry;
            if ( is_dir( $full_path ) ) {
                if ( in_array( $entry, self::EXCLUDED_DIRS, true ) || $this->is_excluded_path( $full_path ) ) {
                    continue;
                }

                yield from $this->iterate_chunk_path( $full_path, $cursor );
                continue;
            }

            $relative = $this->build_chunk_cursor( $full_path );
            if ( $this->is_chunk_candidate( $full_path, $relative, $cursor ) ) {
                yield $relative => $full_path;
            }
        }
    }

    /**
     * Decide whether a file is eligible for chunked scanning.
     */
    private function is_chunk_candidate( string $absolute_path, string $relative_path, string $cursor ): bool {
        if ( '' !== $cursor && strcmp( $relative_path, $cursor ) <= 0 ) {
            return false;
        }

        if ( $this->is_excluded_path( $absolute_path ) ) {
            return false;
        }

        $ext = strtolower( pathinfo( $absolute_path, PATHINFO_EXTENSION ) );

        return is_file( $absolute_path )
            && is_readable( $absolute_path )
            && in_array( $ext, $this->get_scannable_extensions(), true );
    }

    /**
     * Convert an absolute path into a stable relative cursor string.
     */
    private function build_chunk_cursor( string $absolute_path ): string {
        $normalized = str_replace( '\\', '/', $absolute_path );
        $base       = str_replace( '\\', '/', (string) realpath( ABSPATH ) );

        if ( $base && str_starts_with( $normalized, $base ) ) {
            return ltrim( substr( $normalized, strlen( $base ) ), '/' );
        }

        return ltrim( $normalized, '/' );
    }

    /**
     * Build the list of scannable extensions based on current settings.
     *
     * @return string[]
     */
    private function get_scannable_extensions(): array {
        $exts = self::ALL_SCANNABLE_EXTENSIONS;
        if ( empty( $this->settings['antivirus_scan_php'] )  ) { $exts = array_diff( $exts, self::PHP_EXTENSIONS ); }
        if ( empty( $this->settings['antivirus_scan_js'] )   ) { $exts = array_diff( $exts, self::JS_EXTENSIONS ); }
        if ( empty( $this->settings['antivirus_scan_html'] ) ) { $exts = array_diff( $exts, self::HTML_EXTENSIONS ); }
        return array_values( $exts );
    }

    /**
     * Check whether an absolute path matches any user-configured exclude path.
     *
     * @param  string $path  Absolute path to check.
     * @return bool
     */
    private function is_excluded_path( string $path ): bool {
        if ( null !== $this->get_internal_allowlist_reason( $path ) ) {
            return true;
        }

        return $this->is_user_excluded_path( $path );
    }

    /**
     * Return a human-readable skip reason for a file, or null when it should
     * be scanned normally.
     */
    private function get_scan_skip_reason( string $path ): ?string {
        $internal_reason = $this->get_internal_allowlist_reason( $path );
        if ( null !== $internal_reason ) {
            return $internal_reason;
        }

        if ( $this->is_user_excluded_path( $path ) ) {
            return __( 'Skipped because this path is listed in Antivirus excluded paths.', 'ultraguard-security-suite' );
        }

        return null;
    }

    /**
     * Check whether the file is part of UltraGuard's built-in internal
     * allowlist to prevent self-detection.
     */
    private function get_internal_allowlist_reason( string $path ): ?string {
        $normalized_path = self::normalize_path_for_compare( $path );

        foreach ( self::get_internal_allowlisted_files() as $relative_path => $reason ) {
            $absolute_path = self::normalize_path_for_compare( ULTRAGUARD_PRO_PATH . ltrim( str_replace( '/', DIRECTORY_SEPARATOR, $relative_path ), DIRECTORY_SEPARATOR ) );
            if ( $absolute_path === $normalized_path ) {
                return (string) $reason;
            }
        }

        return null;
    }

    /**
     * Check whether an absolute path matches any user-configured exclude path.
     */
    private function is_user_excluded_path( string $path ): bool {
        $raw = (string) ( $this->settings['antivirus_exclude_paths'] ?? '' );
        if ( ! $raw ) { return false; }

        $path = rtrim( str_replace( '\\', '/', $path ), '/' );

        foreach ( explode( "\n", $raw ) as $line ) {
            $line = trim( $line );
            if ( ! $line ) { continue; }

            if ( str_starts_with( $line, '/' ) || ( strlen( $line ) > 1 && $line[1] === ':' ) ) {
                $abs = rtrim( str_replace( '\\', '/', $line ), '/' );
            } else {
                $abs = rtrim( str_replace( '\\', '/', trailingslashit( ABSPATH ) . ltrim( $line, '/' ) ), '/' );
            }

            if ( str_starts_with( $path, $abs ) ) { return true; }
        }

        return false;
    }

    /**
     * Normalize paths for reliable Windows/Linux comparisons.
     */
    private static function normalize_path_for_compare( string $path ): string {
        $resolved = realpath( $path ) ?: $path;
        return rtrim( strtolower( str_replace( '\\', '/', $resolved ) ), '/' );
    }

    /**
     * Build the scan summary array returned by all public run_* methods.
     *
     * @return array{files_scanned: int, threats_found: int, files_skipped: int}
     */
    private function make_summary(): array {
        return [
            'files_scanned' => $this->files_scanned,
            'threats_found' => $this->threats_found,
            'files_skipped' => $this->files_skipped,
        ];
    }

    // ── Static: signature seeding ─────────────────────────────────────────────

    /**
     * Seed the malware signatures table with built-in definitions.
     * Called on first activation and via the "Update Signatures" button.
     *
     * @param  MalwareSignaturesTable $table  The signatures table instance.
     * @return int                            Number of new signatures inserted.
     */
    public static function seed_signatures( MalwareSignaturesTable $table ): int {
        $built_in = [
            // ── Known web shell MD5 hashes ─────────────────────────────────────
            [ 'hash' => '8c7dd922ad47494fc02c388e12c00eac', 'name' => 'Generic PHP Shell',            'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'b14a7b8059d9c055954c92674ce60032', 'name' => 'C99 Shell',                    'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => '098f6bcd4621d373cade4e832627b4f6', 'name' => 'R57 Shell',                    'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => '5d41402abc4b2a76b9719d911017c592', 'name' => 'WSO Shell',                    'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'b026324c6904b2a9cb4b88d6d61c81d1', 'name' => 'Alfa Shell v2',                'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => '26ab0db90d72e28ad0ba1e22ee510510', 'name' => 'IndoXploit Shell',             'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => '6d7fce9fee471194aa8b5b6e47267f03', 'name' => 'FilesMan Shell',               'type' => 'hash',    'level' => 'critical', 'source' => 'ultraguard' ],

            // ── Pattern sentinel entries (mapped to heuristic checks in engine) ─
            [ 'hash' => 'pattern_eval_base64_001',         'name' => 'Eval+Base64 Execution',        'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_rfi_include_001',         'name' => 'Remote File Inclusion',        'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_shell_post_exec_001',     'name' => 'POST-driven Shell Exec',       'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_wp_auth_bypass_001',      'name' => 'WP Auth Bypass Backdoor',      'type' => 'pattern', 'level' => 'high',     'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_dropper_fwrite_001',      'name' => 'Dropper (fwrite + decode)',     'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_crypto_miner_js_001',     'name' => 'Cryptominer JS',               'type' => 'pattern', 'level' => 'high',     'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_card_skimmer_js_001',     'name' => 'Credit Card Skimmer JS',       'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_hidden_iframe_001',       'name' => 'Hidden iframe Injection',      'type' => 'pattern', 'level' => 'medium',   'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_formjack_001',            'name' => 'Form-jacking Script',          'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_high_entropy_001',        'name' => 'High-Entropy Encoded Payload', 'type' => 'heuristic','level' => 'medium',  'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_curl_backdoor_001',       'name' => 'cURL-based Backdoor',          'type' => 'pattern', 'level' => 'high',     'source' => 'ultraguard' ],
            [ 'hash' => 'pattern_php8_named_arg_bypass',   'name' => 'PHP8 Named-Arg Obfuscation',   'type' => 'pattern', 'level' => 'critical', 'source' => 'ultraguard' ],
        ];

        $inserted = 0;
        foreach ( $built_in as $sig ) {
            if ( ! $table->find_by_hash( $sig['hash'] ) ) {
                $table->insert( [
                    'signature_hash' => $sig['hash'],
                    'name'           => $sig['name'],
                    'type'           => $sig['type'],
                    'threat_level'   => $sig['level'],
                    'source'         => $sig['source'],
                    'is_active'      => 1,
                ] );
                $inserted++;
            }
        }
        return $inserted;
    }
}
