<?php
/**
 * FilecheckerEngine — File integrity scan engine.
 *
 * This class owns ALL scanning logic:
 *   • Directory traversal with configurable exclusions
 *   • Multi-algorithm hashing (md5, sha1, sha256)
 *   • WordPress.org official checksum verification
 *   • Malware/suspicious pattern detection
 *   • Scan session lifecycle management
 *   • Scan progress via transient
 *   • Post-scan notification triggers
 *
 * Design rules:
 *   • Does NOT register any WordPress hooks.
 *   • Uses CRUD methods via table objects — no raw $wpdb queries.
 *   • All file I/O behind null-checks; never throws from within a scan.
 *   • Batch-limited to prevent PHP timeout on large installations.
 *
 * @package UltraguardPro\modules\filechecker
 * @since   6.5.0
 */

namespace UltraguardPro\modules\filechecker;

use UltraguardPro\Plugin;
use UltraguardPro\modules\filechecker\db\FileIntegrityTable;
use UltraguardPro\modules\filechecker\db\ScanSessionsTable;

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

class FilecheckerEngine {

    /**
     * Max files processed per full-scan batch.
     *
     * Reduced from 2000 → 500.  The original value combined with one DB query
     * per file routinely exceeded the 120 s cron PHP time limit on sites with
     * large wp-includes trees.  Full scans that still need more files will be
     * picked up by the next scheduled run (twice-daily by default).
     */
    private const BATCH_LIMIT = 500;

    /**
     * Max files per quick-scan batch.
     * Quick scans target only wp-includes + wp-admin; 150 files is plenty for
     * a 90-second budget without hammering the DB.
     */
    private const QUICK_BATCH_LIMIT = 150;

    /**
     * Hard wall-clock deadline (seconds) inside a scan run.
     * Leaves a ~30 s safety margin against the typical 120 s PHP max_execution_time.
     * The loop checks microtime() every iteration and breaks before PHP kills the process.
     */
    private const MAX_SCAN_SECONDS = 90;

    /** Suspicious code patterns that flag a file as potentially malicious. */
    private const MALWARE_PATTERNS = [
        '/eval\s*\(\s*base64_decode/i'            => 'eval(base64_decode)',
        '/eval\s*\(\s*gzinflate/i'                => 'eval(gzinflate)',
        '/eval\s*\(\s*str_rot13/i'                => 'eval(str_rot13)',
        '/\$[a-zA-Z_]+\s*=\s*create_function/i'  => 'create_function backdoor',
        '/assert\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i' => 'assert() injection',
        '/preg_replace\s*\(\s*[\'"]\/.*\/e[\'"]/'=> 'preg_replace /e backdoor',
        '/passthru\s*\(\s*\$_(GET|POST|REQUEST)/i' => 'passthru() injection',
        '/system\s*\(\s*\$_(GET|POST|REQUEST)/i' => 'system() injection',
        '/exec\s*\(\s*\$_(GET|POST|REQUEST)/i'   => 'exec() injection',
        '/shell_exec\s*\(\s*\$_(GET|POST|REQUEST)/i' => 'shell_exec() injection',
        '/base64_decode\s*\(\s*[\'"][A-Za-z0-9+\/]{100,}/i' => 'long base64 blob',
        '/FilesMan/i'                             => 'PHP FilesMan webshell',
        '/WSO\s+Web\s+Shell/i'                   => 'WSO webshell',
        '/c99shell/i'                             => 'c99 webshell',
        '/r57shell/i'                             => 'r57 webshell',
    ];

    /** @var Plugin */
    private Plugin $plugin;

    /** @var array Loaded settings (read once per scan run). */
    private array $settings = [];

    /** @var array<int,array<string,string>> */
    private array $cloud_patterns = [];

    /**
     * In-memory baseline: file_path → DB row, loaded once before the scan loop.
     * Eliminates N per-file get_row() queries (the root cause of cron timeouts).
     *
     * @var array<string, array>
     */
    private array $baseline = [];

    public function __construct( Plugin $plugin ) {
        $this->plugin = $plugin;
    }

    // ── Public API ─────────────────────────────────────────────────────────────

    /**
     * Execute a scan run.
     *
     * Performance model (before fix vs after):
     *   Before: 1 SELECT per file × 2 000 files = ~2 000 queries → 120 s timeout.
     *   After:  1 bulk SELECT for all known paths + 1 batch upsert = ~2 queries
     *           regardless of file count, plus a wall-clock deadline guard.
     *
     * @param string $type  'full' | 'quick'
     */
    public function run( string $type = 'full' ): void {
        $this->load_settings();
        $this->cloud_patterns = $this->load_cloud_patterns();

        $session_table   = $this->table( 'scan_sessions' );
        $integrity_table = $this->table( 'file_integrity' );

        if ( ! $session_table || ! $integrity_table ) {
            $this->log( 'Scan aborted: database tables unavailable.' );
            return;
        }

        // Create scan session record
        $session_id = $this->open_session( $session_table, $type );
        if ( ! $session_id ) {
            $this->log( 'Scan aborted: could not create scan session.' );
            return;
        }

        $this->set_progress( $session_id, $type, 0, 'running' );

        // ── Bulk-preload the baseline ──────────────────────────────────────────
        // One SELECT to load every known file_path → row instead of one per file.
        // This is the primary fix for the cron timeout: it collapses ~N queries
        // into a single query before the loop starts.
        $this->baseline = $integrity_table->get_file_map();

        $dirs  = $this->resolve_scan_dirs( $type );
        $files = $this->collect_files( $dirs, $type );
        $total = count( $files );

        $changes_found = 0;
        $added         = 0;
        $modified      = 0;
        $suspicious    = 0;
        $processed     = 0;

        // Per-type batch ceiling
        $batch_limit = ( $type === 'quick' ) ? self::QUICK_BATCH_LIMIT : self::BATCH_LIMIT;

        // Wall-clock deadline — break before PHP's max_execution_time kills us.
        $deadline = microtime( true ) + self::MAX_SCAN_SECONDS;

        foreach ( $files as $path ) {
            // Hard limits: batch ceiling AND wall-clock deadline
            if ( $processed >= $batch_limit )      { break; }
            if ( microtime( true ) >= $deadline )  {
                $this->log( "Scan paused at {$processed} files: approaching time limit." );
                break;
            }

            try {
                $result = $this->scan_file( $integrity_table, $path );
                $processed++;

                match ( $result ) {
                    'added'      => [ $added++,      $changes_found++ ],
                    'modified'   => [ $modified++,   $changes_found++ ],
                    'suspicious' => [ $suspicious++,  $changes_found++ ],
                    default      => null,
                };

                // Update progress every 25 files for smooth real-time feedback
                if ( $processed % 25 === 0 || $processed === 1 ) {
                    $pct = $total > 0 ? (int) round( ( $processed / $total ) * 100 ) : 0;
                    $this->set_progress( $session_id, $type, $pct, 'running', $path, $changes_found, $processed );
                }

            } catch ( \Throwable $e ) {
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                    \UltraguardPro\core\Logger::error( '[UltraGuard FileChecker] scan_file error: ' . $e->getMessage() );
                }
            }
        }

        // Release baseline memory — no longer needed after the loop
        $this->baseline = [];
        $this->cloud_patterns = [];

        // WordPress.org checksum verification (core only, pro feature)
        $core_mismatches = 0;
        if ( $type === 'full' && $this->settings['compare_remote'] ) {
            $core_mismatches = $this->verify_wp_checksums( $integrity_table );
            $changes_found  += $core_mismatches;
        }

        $this->close_session( $session_table, $session_id, [
            'status'        => 'completed',
            'files_scanned' => $processed,
            'changes_found' => $changes_found,
            'added_files'   => $added,
            'modified_files'=> $modified,
            'suspicious'    => $suspicious,
            'completed_at'  => current_time( 'mysql', true ),
        ] );

        $this->clear_progress();

        // Flush all table caches so the next page load reads fresh counts from DB
        foreach ( [ 'file_integrity', 'scan_sessions', 'fc_quarantine' ] as $key ) {
            $t = $this->table( $key );
            if ( $t ) { $t->clear_all_caches(); }
        }

        // Fire post-scan action for Notifications module to hook into
        do_action( 'ultraguard_filechecker_scan_complete', [
            'type'           => $type,
            'session_id'     => $session_id,
            'files_scanned'  => $processed,
            'changes_found'  => $changes_found,
            'added'          => $added,
            'modified'       => $modified,
            'suspicious'     => $suspicious,
        ] );

        if ( $changes_found > 0 ) {
            do_action( 'ultraguard_filechecker_changes_detected', $changes_found, $session_id );
        }
    }

    /**
     * Quarantine a file: move it to the quarantine directory and record in DB.
     *
     * @param  string $file_path   Absolute path to the file to quarantine.
     * @param  string $reason      Short reason string (e.g. 'suspicious', 'manual').
     * @return string|false        Quarantine destination path or false on failure.
     */
    public function quarantine_file( string $file_path, string $reason = 'manual' ): string|false {
        if ( ! file_exists( $file_path ) ) { return false; }

        $qdir = trailingslashit( wp_upload_dir()['basedir'] ) . 'ultraguard-security-suite/quarantine/';
        wp_mkdir_p( $qdir );

        // Prevent directory listing and direct web access
        if ( ! file_exists( $qdir . '.htaccess' ) ) {
            file_put_contents( $qdir . '.htaccess', "Deny from all
Options -Indexes
" );
        }
        if ( ! file_exists( $qdir . 'index.php' ) ) {
            file_put_contents( $qdir . 'index.php', '<?php // silence' );
        }

        $dest = $qdir . sanitize_file_name( basename( $file_path ) )
              . '.' . time() . '.quar';

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

        // Record in quarantine table
        $q_table = $this->table( 'fc_quarantine' );
        if ( $q_table ) {
            $q_table->insert( [
                'original_path'   => $file_path,
                'quarantine_path' => $dest,
                'reason'          => sanitize_text_field( $reason ),
                'file_size'       => file_exists( $dest ) ? filesize( $dest ) : 0,
                'file_hash'       => file_exists( $dest ) ? hash_file( 'sha256', $dest ) : '',
                'quarantined_at'  => current_time( 'mysql', true ),
                'status'          => 'quarantined',
            ] );
        }

        // Update integrity table status
        $i_table = $this->table( 'file_integrity' );
        if ( $i_table ) {
            $row = $i_table->get_row( [ 'file_path' => $file_path ] );
            if ( $row && ! empty( $row['id'] ) ) {
                $i_table->update( (int) $row['id'], [
                    'status'          => 'quarantined',
                    'quarantine_path' => $dest,
                ] );
            }
        }

        return $dest;
    }

    /**
     * Restore a quarantined file back to its original location.
     *
     * @param  int $quarantine_id  Row ID in fc_quarantine table.
     * @return bool
     */
    public function restore_file( int $quarantine_id ): bool {
        $q_table = $this->table( 'fc_quarantine' );
        if ( ! $q_table ) { return false; }

        $row = $q_table->get( $quarantine_id );
        if ( empty( $row['quarantine_path'] ) || empty( $row['original_path'] ) ) {
            return false;
        }

        if ( ! file_exists( $row['quarantine_path'] ) ) { return false; }

        // Restore the file
        $dir = dirname( $row['original_path'] );
        wp_mkdir_p( $dir );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic restore from quarantine within the local filesystem.
        if ( ! @rename( $row['quarantine_path'], $row['original_path'] ) ) {
            return false;
        }

        $q_table->update( $quarantine_id, [ 'status' => 'restored' ] );

        // Re-hash restored file and mark as approved
        $i_table = $this->table( 'file_integrity' );
        if ( $i_table ) {
            $new_hash = hash_file( 'sha256', $row['original_path'] );
            $i_row    = $i_table->get_row( [ 'file_path' => $row['original_path'] ] );
            if ( $i_row && ! empty( $i_row['id'] ) ) {
                $i_table->update( (int) $i_row['id'], [
                    'status'          => 'clean',
                    'file_hash'       => $new_hash,
                    'is_approved'     => 1,
                    'quarantine_path' => null,
                ] );
            }
        }

        return true;
    }

    // ── Private scan helpers ───────────────────────────────────────────────────

    /**
     * Load scan settings from the settings store into $this->settings.
     */
    private function load_settings(): void {
        $s = fn( $k, $d ) => $this->plugin->settings->get_option( $k, $d );

        $this->settings = [
            'scan_core'       => (bool) $s( 'filechecker_scan_core',       true ),
            'scan_plugins'    => (bool) $s( 'filechecker_scan_plugins',    true ),
            'scan_themes'     => (bool) $s( 'filechecker_scan_themes',     true ),
            'scan_uploads'    => (bool) $s( 'filechecker_scan_uploads',    false ),
            'extra_dirs'      => (array) $s( 'filechecker_extra_dirs',     [] ),
            'exclude_paths'   => $this->parse_exclusion_list( (string) $s( 'filechecker_exclude_paths', '' ) ),
            'exclude_exts'    => $this->parse_exclusion_exts( (string) $s( 'filechecker_exclude_extensions', '' ) ),
            'hash_algo'       => (string) $s( 'filechecker_hash_algorithm', 'sha256' ),
            'compare_remote'  => (bool) $s( 'filechecker_compare_remote',  true ),
            'max_file_size'   => (int)  $s( 'filechecker_max_file_size',    50 ) * 1024 * 1024, // → bytes
            'malware_scan'    => (bool) $s( 'filechecker_malware_scan',    true ),
        ];
    }

    /**
     * Resolve which directories to scan based on type and settings.
     *
     * @param  string $type 'full' | 'quick'
     * @return array<string> Absolute directory paths.
     */
    private function resolve_scan_dirs( string $type ): array {
        $dirs = [];

        if ( $type === 'quick' || $this->settings['scan_core'] ) {
            $dirs[] = ABSPATH . 'wp-includes';
            $dirs[] = ABSPATH . 'wp-admin';
        }

        if ( $type === 'full' ) {
            if ( $this->settings['scan_plugins'] ) { $dirs[] = WP_CONTENT_DIR . '/plugins'; }
            if ( $this->settings['scan_themes'] )  { $dirs[] = WP_CONTENT_DIR . '/themes'; }
            if ( $this->settings['scan_uploads'] ) { $dirs[] = WP_CONTENT_DIR . '/uploads'; }

            foreach ( $this->settings['extra_dirs'] as $d ) {
                $d = sanitize_text_field( (string) $d );
                if ( $d && is_dir( $d ) ) { $dirs[] = $d; }
            }
        }

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

    /**
     * Recursively collect all scannable files from a list of directories.
     *
     * @param  array  $dirs
     * @param  string $type  'full' | 'quick' — controls the collection ceiling.
     * @return array<string> Absolute file paths.
     */
    private function collect_files( array $dirs, string $type = 'full' ): array {
        $files       = [];
        $exclude_px  = $this->settings['exclude_paths'];
        $exclude_ext = $this->settings['exclude_exts'];
        $max_bytes   = $this->settings['max_file_size'];
        $ceiling     = ( $type === 'quick' ) ? self::QUICK_BATCH_LIMIT : self::BATCH_LIMIT;

        foreach ( $dirs as $dir ) {
            if ( ! is_dir( $dir ) ) { continue; }

            try {
                // Use RecursiveCallbackFilterIterator to properly skip excluded directory
                // subtrees. SELF_FIRST + $it->next() only skips one entry, not the whole
                // subtree — this caused excluded directories to still be traversed.
                $base_it  = new \RecursiveDirectoryIterator(
                    $dir,
                    \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS
                );
                $filter   = new \RecursiveCallbackFilterIterator(
                    $base_it,
                    function ( \SplFileInfo $f, $key, \RecursiveDirectoryIterator $inner )
                        use ( $exclude_px ) {
                        if ( $inner->hasChildren() ) {
                            $dp = rtrim( $f->getPathname(), '/' );
                            foreach ( $exclude_px as $ex ) {
                                if ( str_starts_with( $dp, $ex ) ) { return false; }
                            }
                        }
                        return true;
                    }
                );
                $it = new \RecursiveIteratorIterator(
                    $filter,
                    \RecursiveIteratorIterator::LEAVES_ONLY,
                    \RecursiveIteratorIterator::CATCH_GET_CHILD
                );

                foreach ( $it as $file ) {
                    if ( ! $file->isFile() ) { continue; }

                    $ext  = strtolower( $file->getExtension() );
                    $path = $file->getPathname();

                    // Extension filter
                    if ( ! empty( $exclude_ext ) && in_array( $ext, $exclude_ext, true ) ) { continue; }

                    // Size limit
                    if ( $max_bytes > 0 && $file->getSize() > $max_bytes ) { continue; }

                    $files[] = $path;

                    if ( count( $files ) >= $ceiling ) { break 2; }
                }
            } catch ( \Throwable $e ) {
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                    \UltraguardPro\core\Logger::error( '[UltraGuard FileChecker] collect_files error: ' . $e->getMessage() );
                }
            }
        }

        return $files;
    }

    /**
     * Scan a single file: hash it, compare against baseline, run malware
     * patterns, then upsert the DB record.
     *
     * The $table parameter is still accepted (used for the upsert write), but
     * the read-side lookup now comes from $this->baseline — a map preloaded
     * once before the scan loop — eliminating the per-file get_row() query
     * that was the root cause of cron timeouts.
     *
     * @param  FileIntegrityTable $table
     * @param  string             $path
     * @return string  'clean' | 'added' | 'modified' | 'suspicious'
     */
    private function scan_file( FileIntegrityTable $table, string $path ): string {
        $algo    = $this->settings['hash_algo'];
        $hash    = $this->hash_file( $path, $algo );
        $size    = (int) @filesize( $path );
        $is_core = $this->is_core_file( $path );
        $ext     = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );

        // O(1) map lookup instead of a DB SELECT per file.
        $existing  = $this->baseline[ $path ] ?? null;
        $status    = 'clean';
        $change_t  = '';
        $prev_hash = '';

        if ( $existing !== null && ! empty( $existing['file_hash'] ) ) {
            if ( $existing['file_hash'] !== $hash ) {
                $status    = 'modified';
                $change_t  = 'content_changed';
                $prev_hash = $existing['file_hash'];
            }
        } else {
            $status   = 'added';
            $change_t = 'new_file';
        }

        // Malware pattern scan: run on ALL PHP files when enabled.
        // Running only on changed files ($status !== 'clean') would miss backdoors
        // injected before the baseline was established — attackers know this trick.
        if ( $this->settings['malware_scan'] && $ext === 'php' ) {
            $pattern_match = $this->scan_malware_patterns( $path );
            if ( $pattern_match ) {
                $status   = 'suspicious';
                $change_t = 'malware_pattern:' . $pattern_match;
            }
        }

        // Fast upsert: supplies the pre-loaded row so upsert_file_fast() skips
        // its own get_row() call — the second part of the N+1 elimination.
        $row_id = $table->upsert_file_fast(
            $path, $hash, $size, $status, $prev_hash, $change_t, $is_core, $existing
        );

        // Keep baseline in sync so repeated runs within the same process stay
        // consistent (e.g. two back-to-back manual scans in one request).
        if ( $row_id ) {
            $this->baseline[ $path ] = array_merge(
                $existing ?? [],
                [
                    'id'        => $row_id,
                    'file_path' => $path,
                    'file_hash' => $hash,
                    'status'    => $status,
                ]
            );
        }

        // Fire hooks so ServerCommunication can push events to the cloud
        if ( $status === 'suspicious' ) {
            do_action( 'ultraguard_pro/filechecker/suspicious_file', $path, [
                'file_hash'    => $hash,
                'status'       => $status,
                'change_type'  => $change_t,
                'is_core_file' => $is_core,
            ] );
        } elseif ( $status === 'modified' && $is_core ) {
            do_action( 'ultraguard_pro/filechecker/file_modified', $path, [
                'file_hash'     => $hash,
                'previous_hash' => $prev_hash,
                'is_core_file'  => $is_core,
                'change_type'   => $change_t,
            ] );
        }

        return $status;
    }

    /**
     * Verify WordPress core files against WordPress.org official checksums.
     * Only runs once per full scan to avoid excessive API calls.
     *
     * @param  FileIntegrityTable $table
     * @return int  Number of checksum mismatches found.
     */
    private function verify_wp_checksums( FileIntegrityTable $table ): int {
        global $wp_version;

        // Respect a per-day rate limit
        $transient_key = 'ultraguard_fc_checksums_checked';
        if ( get_transient( $transient_key ) ) { return 0; }

        $locale = get_locale();
        if ( ! function_exists( 'get_core_checksums' ) ) {
            require_once ABSPATH . 'wp-admin/includes/update.php';
        }

        $checksums = function_exists( 'get_core_checksums' ) ? get_core_checksums( $wp_version, $locale ) : [];

        if ( ! is_array( $checksums ) ) { return 0; }

        set_transient( $transient_key, 1, DAY_IN_SECONDS );

        $mismatches = 0;
        $algo       = $this->settings['hash_algo'];

        foreach ( $checksums as $relative_path => $expected_md5 ) {
            $abs_path = ABSPATH . $relative_path;

            if ( ! file_exists( $abs_path ) ) { continue; }

            // WP.org provides MD5; compute md5 locally for comparison
            $actual = md5_file( $abs_path );

            if ( $actual !== $expected_md5 ) {
                // Also compute with configured algo for the DB record
                $hash = $this->hash_file( $abs_path, $algo );
                $table->upsert_file(
                    $abs_path,
                    $hash,
                    (int) @filesize( $abs_path ),
                    'suspicious',
                    '',
                    'wp_checksum_mismatch',
                    true
                );
                $mismatches++;
            }
        }

        return $mismatches;
    }

    /**
     * Scan file content for known malware patterns.
     *
     * @param  string $path
     * @return string  Matched pattern label or empty string if clean.
     */
    private function scan_malware_patterns( string $path ): string {
        $content = @file_get_contents( $path, false, null, 0, 131072 ); // read max 128 KB
        if ( ! $content ) { return ''; }

        foreach ( self::MALWARE_PATTERNS as $regex => $label ) {
            if ( preg_match( $regex, $content ) ) {
                return $label;
            }
        }

        foreach ( $this->cloud_patterns as $signature ) {
            $regex = (string) ( $signature['pattern'] ?? '' );
            $label = (string) ( $signature['name'] ?? 'Cloud signature' );

            if ( '' === $regex || @preg_match( $regex, '' ) === false ) {
                continue;
            }

            if ( @preg_match( $regex, $content ) ) {
                return $label;
            }
        }

        return '';
    }

    /**
     * Load server-fed supplemental filechecker patterns.
     *
     * @return array<int,array<string,string>>
     */
    private function load_cloud_patterns(): array {
        $patterns = get_option( 'ultraguard_cloud_filechecker_patterns', [] );
        if ( ! is_array( $patterns ) ) {
            return [];
        }

        return array_values( array_filter( array_map(
            static function( $pattern ): ?array {
                if ( ! is_array( $pattern ) ) {
                    return null;
                }

                $regex = (string) ( $pattern['pattern'] ?? '' );
                if ( '' === $regex || @preg_match( $regex, '' ) === false ) {
                    return null;
                }

                return [
                    'name'         => sanitize_text_field( (string) ( $pattern['name'] ?? 'Cloud signature' ) ),
                    'pattern'      => $regex,
                    'threat_level' => sanitize_key( (string) ( $pattern['threat_level'] ?? 'high' ) ),
                ];
            },
            $patterns
        ) ) );
    }

    // ── Session helpers ────────────────────────────────────────────────────────

    /**
     * Open a new scan session record.
     *
     * @param  ScanSessionsTable $table
     * @param  string            $type
     * @return int|false  New session row ID or false.
     */
    private function open_session( ScanSessionsTable $table, string $type ): int|false {
        return $table->insert( [
            'scan_type'  => $type,
            'status'     => 'running',
            'started_at' => current_time( 'mysql', true ),
            'initiated_by' => get_current_user_id() ?: 0,
        ] );
    }

    /**
     * Close a scan session with final counts.
     *
     * @param  ScanSessionsTable $table
     * @param  int               $session_id
     * @param  array             $data
     */
    private function close_session( ScanSessionsTable $table, int $session_id, array $data ): void {
        $table->update( $session_id, $data );
    }

    // ── Scan progress transient ────────────────────────────────────────────────

    /**
     * Store scan progress in a short-lived transient.
     *
     * @param  int    $session_id
     * @param  string $type
     * @param  int    $percent
     * @param  string $status
     * @param  string $current_file     Absolute path of the file currently being hashed.
     * @param  int    $changes_found    Running count of changes detected so far.
     * @param  int    $files_processed  Running count of files hashed so far.
     */
    private function set_progress(
        int    $session_id,
        string $type,
        int    $percent,
        string $status,
        string $current_file    = '',
        int    $changes_found   = 0,
        int    $files_processed = 0
    ): void {
        // Preserve the original started_at so the JS elapsed timer stays accurate.
        $existing   = get_transient( 'ultraguard_fc_scan_in_progress' );
        $started_at = ( $existing && isset( $existing['started_at'] ) )
            ? $existing['started_at']
            : time();

        set_transient( 'ultraguard_fc_scan_in_progress', [
            'session_id'      => $session_id,
            'type'            => $type,
            'percent'         => $percent,
            'status'          => $status,
            'started_at'      => $started_at,
            'current_file'    => $current_file,
            'changes_found'   => $changes_found,
            'files_processed' => $files_processed,
        ], 7200 );
    }

    /**
     * Delete scan progress transient once scan completes.
     */
    private function clear_progress(): void {
        delete_transient( 'ultraguard_fc_scan_in_progress' );
    }

    // ── Utility helpers ────────────────────────────────────────────────────────

    /**
     * Hash a file using the configured algorithm.
     *
     * @param  string $path
     * @param  string $algo  'md5' | 'sha1' | 'sha256'
     * @return string
     */
    public function hash_file( string $path, string $algo = 'sha256' ): string {
        return match ( $algo ) {
            'sha256' => (string) @hash_file( 'sha256', $path ),
            'sha1'   => (string) @sha1_file( $path ),
            default  => (string) @md5_file( $path ),
        };
    }

    /**
     * Determine whether a path lives inside the WordPress core directories.
     *
     * @param  string $path
     * @return bool
     */
    private function is_core_file( string $path ): bool {
        return str_starts_with( $path, ABSPATH . 'wp-includes' )
            || str_starts_with( $path, ABSPATH . 'wp-admin' );
    }

    /**
     * Parse a newline-separated exclusion path list into an array of absolute paths.
     *
     * @param  string $raw
     * @return array<string>
     */
    private function parse_exclusion_list( string $raw ): array {
        $paths = [];
        foreach ( explode( "\n", $raw ) as $line ) {
            $line = trim( $line );
            if ( ! $line ) { continue; }
            // Allow relative (to ABSPATH) or absolute paths
            $abs = str_starts_with( $line, '/' ) ? $line : ABSPATH . ltrim( $line, '/' );
            $paths[] = rtrim( $abs, '/' );
        }
        return $paths;
    }

    /**
     * Parse a comma-separated extension exclusion list.
     *
     * @param  string $raw  e.g. "jpg, png, log"
     * @return array<string>
     */
    private function parse_exclusion_exts( string $raw ): array {
        if ( ! $raw ) { return []; }
        return array_filter( array_map( 'trim', explode( ',', strtolower( $raw ) ) ) );
    }

    /**
     * Retrieve a table object by key.
     *
     * @param  string $key
     * @return \UltraguardPro\db\BaseTable|null
     */
    private function table( string $key ) {
        $db = $this->plugin->modules->get_db_tables();
        return $db ? $db->get_table( $key ) : null;
    }

    /**
     * Write a debug-gated log entry.
     *
     * @param string $msg
     */
    private function log( string $msg ): void {
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            \UltraguardPro\core\Logger::error( '[UltraGuard FileChecker] ' . $msg );
        }
    }
}
