<?php
/**
 * File-Integrity Monitor + scan orchestrator.
 *
 * Maintains a SHA-256 baseline for every PHP / .htaccess file under WP core,
 * mu-plugins, plugins and themes. Daily wp-cron job re-hashes every tracked
 * file and emits FILE_ADDED / FILE_CHANGED / FILE_MISSING events when the
 * world drifts away from the baseline.
 *
 * Pattern-based malware checks are delegated to Shieldforge_Pattern_Scanner;
 * this class wires them in by running the pattern scan against any new or
 * modified file detected during the integrity sweep.
 *
 * Free-tier promise:
 *  - 100% local. No file contents leave the server.
 *  - No external API calls (the Pro tier may add wp.org checksum cross-check
 *    for known core versions; this class is structured so that hook-in is a
 *    single-method addition).
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Scanner {

    /** Sources we tag baseline rows with so we can group findings by area. */
    const SOURCE_CORE      = 'core';
    const SOURCE_PLUGIN    = 'plugin';
    const SOURCE_MU_PLUGIN = 'mu-plugin';
    const SOURCE_THEME     = 'theme';
    const SOURCE_ROOT      = 'root';

    /** Row states on the files table. */
    const STATE_TRACKED     = 'tracked';
    const STATE_WHITELISTED = 'whitelisted';

    /** Cron hook for the scheduled daily sweep. */
    const CRON_HOOK = 'shieldforge_daily_scan';

    /** Soft time budget per cron run, seconds. Long sites resume next tick. */
    const TIME_BUDGET = 45;

    /** Scan progress stored under this site-option key (multisite-aware). */
    const PROGRESS_OPT = 'shieldforge_scan_progress';

    /** Default-skip directory segments (relative to ABSPATH). */
    private static $default_skip_dirs = array(
        'wp-content/uploads',
        'wp-content/cache',
        'wp-content/backup',
        'wp-content/backups',
        'wp-content/upgrade',
        'wp-content/upgrade-temp-backup',
        'wp-content/wflogs',
        'wp-content/w3tc-config',
        'wp-content/et-cache',
        'wp-content/litespeed',
        '.git',
        '.svn',
        'node_modules',
        'vendor',
        '__MACOSX',
    );

    /** Extensions to fingerprint by default. */
    private static $default_ext = array( 'php', 'phtml', 'phar', 'inc' );

    /* ==================================================================
     *  Registration / cron
     * ================================================================== */

    public static function register( Shieldforge_Loader $loader ) {
        $loader->add_action( self::CRON_HOOK, __CLASS__, 'cron_run' );
    }

    /**
     * Schedule the daily scan if not already scheduled.
     * Called from the activator and on settings save.
     */
    public static function ensure_scheduled() {
        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            // Stagger from digest so they don't both punch the DB at 07:00.
            wp_schedule_event( strtotime( 'tomorrow 03:30' ), 'daily', self::CRON_HOOK );
        }
    }

    public static function unschedule() {
        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( $next ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
        }
    }

    public static function cron_run() {
        if ( ! Shieldforge_Settings::get( 'scanner_enabled', true ) ) {
            return;
        }
        // If no baseline exists yet, build one. Otherwise diff.
        if ( 0 === self::baseline_count() ) {
            self::build_baseline();
        } else {
            self::scan_now();
        }
    }

    /* ==================================================================
     *  Schema
     * ================================================================== */

    /**
     * SQL for the baseline table. Called from the activator and from a v2
     * migration (existing installs upgrade in place).
     */
    public static function table_sql() {
        global $wpdb;
        $charset_collate = $wpdb->get_charset_collate();
        // Use $wpdb directly here (NOT files_table()) because the activator
        // calls this before Shieldforge_Utils is loaded — same pattern as
        // the other tables in Shieldforge_Activator::create_tables().
        $files_table     = $wpdb->base_prefix . 'shieldforge_files';

        // VARBINARY for path because filenames are bytes, not necessarily UTF-8.
        // path_hash (SHA-1 of full path) gives us a fixed-width unique key
        // without bumping into MySQL's index-byte-length limit.
        return "CREATE TABLE {$files_table} (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            path_hash CHAR(40) NOT NULL,
            path VARBINARY(512) NOT NULL,
            file_hash CHAR(64) NOT NULL,
            file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
            file_mtime INT UNSIGNED NOT NULL DEFAULT 0,
            source VARCHAR(20) NOT NULL DEFAULT 'core',
            state VARCHAR(20) NOT NULL DEFAULT 'tracked',
            baseline_at DATETIME NOT NULL,
            updated_at DATETIME NOT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY uniq_path (path_hash),
            KEY idx_source (source),
            KEY idx_state (state)
        ) {$charset_collate};";
    }

    public static function files_table() {
        return Shieldforge_Utils::table( 'files' );
    }

    /**
     * Idempotent table creation — used by the v2 migration.
     */
    public static function create_table() {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( self::table_sql() );
    }

    public static function baseline_count() {
        global $wpdb;
        $table = self::files_table();
        // get_var returns null when table doesn't exist yet; treat as 0.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; no user values in this query.
        $n = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
        return (int) $n;
    }

    /* ==================================================================
     *  Path-set discovery
     * ================================================================== */

    /**
     * Yields every absolute file path that should be tracked.
     * Memory-friendly: iterator, not array.
     *
     * Source-tag is derived per-file via source_for_path() rather than
     * trusting the iteration root, because the ABSPATH root already
     * recurses into wp-admin/ and wp-includes/. We dedup by realpath so a
     * file is yielded exactly once regardless of which root reaches it.
     *
     * @return Generator<array{0:string,1:string}> tuples of [absolute_path, source]
     */
    public static function iter_tracked_paths() {
        $skip = self::skip_dirs();
        $ext  = self::tracked_extensions();
        $seen = array();

        $roots = self::roots();
        foreach ( $roots as $abs_root => $_root_source_unused ) {
            if ( ! is_dir( $abs_root ) ) {
                continue;
            }
            try {
                $iter = new RecursiveIteratorIterator(
                    new RecursiveCallbackFilterIterator(
                        new RecursiveDirectoryIterator( $abs_root, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS ),
                        function ( $current ) use ( $skip ) {
                            if ( $current->isDir() ) {
                                return ! self::path_skipped( $current->getPathname(), $skip );
                            }
                            return true;
                        }
                    ),
                    RecursiveIteratorIterator::LEAVES_ONLY,
                    RecursiveIteratorIterator::CATCH_GET_CHILD
                );
            } catch ( Exception $e ) {
                continue;
            }
            foreach ( $iter as $file ) {
                if ( ! $file->isFile() ) {
                    continue;
                }
                $name = $file->getFilename();
                $extn = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
                $ok   = in_array( $extn, $ext, true ) || '.htaccess' === $name || 'wp-config.php' === $name;
                if ( ! $ok ) {
                    continue;
                }
                $abs_path = $file->getPathname();
                // Dedup by normalized path: ABSPATH root and wp-includes root
                // both reach the same files; we only want to emit each once.
                $key = str_replace( '\\', '/', $abs_path );
                if ( isset( $seen[ $key ] ) ) {
                    continue;
                }
                $seen[ $key ] = true;
                yield array( $abs_path, self::source_for_path( $abs_path ) );
            }
        }
    }

    /**
     * Returns map of [absolute_root_dir => source-tag].
     */
    public static function roots() {
        $roots = array();

        // Core: ABSPATH (top-level php), wp-admin, wp-includes.
        $roots[ untrailingslashit( ABSPATH ) ]                   = self::SOURCE_ROOT;
        $roots[ untrailingslashit( ABSPATH . 'wp-admin' ) ]      = self::SOURCE_CORE;
        $roots[ untrailingslashit( ABSPATH . 'wp-includes' ) ]   = self::SOURCE_CORE;

        // Plugins.
        if ( defined( 'WP_PLUGIN_DIR' ) && is_dir( WP_PLUGIN_DIR ) ) {
            $roots[ untrailingslashit( WP_PLUGIN_DIR ) ] = self::SOURCE_PLUGIN;
        }
        // MU plugins.
        if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) ) {
            $roots[ untrailingslashit( WPMU_PLUGIN_DIR ) ] = self::SOURCE_MU_PLUGIN;
        }
        // Themes (multiple roots possible).
        if ( function_exists( 'get_theme_roots' ) ) {
            $theme_roots = get_theme_roots();
            $theme_roots = is_array( $theme_roots ) ? $theme_roots : array( $theme_roots );
            foreach ( array_unique( $theme_roots ) as $tr ) {
                $abs = untrailingslashit( WP_CONTENT_DIR . $tr );
                if ( is_dir( $abs ) ) {
                    $roots[ $abs ] = self::SOURCE_THEME;
                }
            }
        }

        // Allow site-specific add/remove via filter (advanced).
        $roots = (array) apply_filters( 'shieldforge_scanner_roots', $roots );
        return $roots;
    }

    public static function skip_dirs() {
        $skip = self::$default_skip_dirs;
        $extra = (array) Shieldforge_Settings::get( 'scanner_skip_dirs', array() );
        $skip = array_merge( $skip, $extra );
        // Normalize to forward-slashes for cross-platform matching.
        return array_unique( array_map( function ( $d ) { return str_replace( '\\', '/', trim( $d, "/\\ \t\n\r" ) ); }, $skip ) );
    }

    public static function tracked_extensions() {
        $ext = (array) Shieldforge_Settings::get( 'scanner_extensions', self::$default_ext );
        return array_filter( array_map( function ( $e ) {
            return strtolower( ltrim( (string) $e, '.' ) );
        }, $ext ) );
    }

    private static function path_skipped( $abs_path, array $skip ) {
        $needle = str_replace( '\\', '/', $abs_path );
        foreach ( $skip as $seg ) {
            if ( '' === $seg ) {
                continue;
            }
            if ( false !== strpos( $needle, '/' . $seg ) || false !== strpos( $needle, '/' . $seg . '/' ) ) {
                return true;
            }
        }
        return false;
    }

    /* ==================================================================
     *  Baseline build
     * ================================================================== */

    /**
     * First-time baseline: hash everything we can within the time budget.
     * Resumes in the next cron tick if it doesn't finish.
     *
     * @return array stats
     */
    public static function build_baseline() {
        $started  = microtime( true );
        $progress = self::get_progress();
        $cursor   = isset( $progress['cursor'] ) ? (int) $progress['cursor'] : 0;
        $skipped  = 0;
        $hashed   = 0;
        $errors   = 0;
        $i        = 0;
        $now      = current_time( 'mysql', 1 );

        foreach ( self::iter_tracked_paths() as list( $abs, $source ) ) {
            $i++;
            if ( $i <= $cursor ) {
                $skipped++;
                continue;
            }
            $row = self::fingerprint( $abs );
            if ( null === $row ) {
                $errors++;
                continue;
            }
            self::upsert_baseline( $abs, $row['hash'], $row['size'], $row['mtime'], $source, $now );
            $hashed++;

            // Cooperative time budget. Don't block the cron worker forever
            // on a 50k-file install.
            if ( ( $hashed % 64 ) === 0 && ( microtime( true ) - $started ) > self::TIME_BUDGET ) {
                self::set_progress( array( 'cursor' => $i, 'phase' => 'baseline', 'updated_at' => $now ) );
                self::log_event( 'scanner_baseline_progress', Shieldforge_Logger::SEV_INFO, array(
                    'cursor'      => $i,
                    'hashed_run'  => $hashed,
                    'budget_secs' => self::TIME_BUDGET,
                ) );
                return array( 'hashed' => $hashed, 'skipped' => $skipped, 'errors' => $errors, 'finished' => false );
            }
        }

        // Finished a full pass.
        self::set_progress( array( 'cursor' => 0, 'phase' => 'idle', 'updated_at' => $now, 'last_baseline_at' => $now ) );
        self::log_event( 'scanner_baseline_complete', Shieldforge_Logger::SEV_INFO, array(
            'tracked_files' => self::baseline_count(),
            'hashed_run'    => $hashed,
            'errors'        => $errors,
        ) );
        return array( 'hashed' => $hashed, 'skipped' => $skipped, 'errors' => $errors, 'finished' => true );
    }

    /**
     * Re-hash + diff against the stored baseline. Logs ADDED / CHANGED /
     * MISSING events; runs the pattern scanner against new+modified files.
     *
     * @return array stats
     */
    public static function scan_now() {
        $started  = microtime( true );
        $progress = self::get_progress();
        $cursor   = isset( $progress['cursor'] ) && ( $progress['phase'] ?? '' ) === 'scan' ? (int) $progress['cursor'] : 0;
        $now      = current_time( 'mysql', 1 );

        // Mark every tracked row as "not seen yet this pass" by
        // remembering the start time; rows whose updated_at is older than
        // started_at after the pass are MISSING.
        $sweep_started_at = current_time( 'mysql', 1 );

        $i = 0;
        $stats = array(
            'added' => 0, 'changed' => 0, 'missing' => 0, 'malware' => 0, 'scanned' => 0, 'finished' => false,
        );

        foreach ( self::iter_tracked_paths() as list( $abs, $source ) ) {
            $i++;
            if ( $i <= $cursor ) {
                continue;
            }
            $row = self::fingerprint( $abs );
            if ( null === $row ) {
                continue;
            }
            $existing = self::get_baseline_row( $abs );
            if ( ! $existing ) {
                self::upsert_baseline( $abs, $row['hash'], $row['size'], $row['mtime'], $source, $now );
                self::log_event(
                    Shieldforge_Logger::EVT_FILE_ADDED,
                    Shieldforge_Logger::SEV_WARN,
                    array(
                        'path'   => self::short_path( $abs ),
                        'size'   => $row['size'],
                        'source' => $source,
                    )
                );
                $stats['added']++;
                self::pattern_scan_file( $abs, $source, $stats );
            } elseif ( self::STATE_WHITELISTED === $existing->state ) {
                // User chose to ignore this path; refresh baseline silently.
                self::upsert_baseline( $abs, $row['hash'], $row['size'], $row['mtime'], $source, $now );
            } elseif ( $existing->file_hash !== $row['hash'] ) {
                self::upsert_baseline( $abs, $row['hash'], $row['size'], $row['mtime'], $source, $now );
                self::log_event(
                    Shieldforge_Logger::EVT_FILE_CHANGED,
                    self::sev_for_change( $source ),
                    array(
                        'path'         => self::short_path( $abs ),
                        'size_old'     => (int) $existing->file_size,
                        'size_new'     => $row['size'],
                        'mtime_old'    => (int) $existing->file_mtime,
                        'mtime_new'    => $row['mtime'],
                        'source'       => $source,
                        'baseline_at'  => $existing->baseline_at,
                    )
                );
                $stats['changed']++;
                self::pattern_scan_file( $abs, $source, $stats );
            } else {
                // Hash unchanged — touch updated_at so we know it was seen.
                self::touch_baseline( $abs, $now );
            }
            $stats['scanned']++;

            if ( ( $stats['scanned'] % 64 ) === 0 && ( microtime( true ) - $started ) > self::TIME_BUDGET ) {
                self::set_progress( array( 'cursor' => $i, 'phase' => 'scan', 'updated_at' => $now ) );
                return $stats;
            }
        }

        // Pass finished. MISSING = baseline rows with updated_at older than
        // when this sweep started. Also drop the row, otherwise next pass
        // re-emits the missing event forever.
        $missing = self::find_and_log_missing( $sweep_started_at );
        $stats['missing'] = $missing;
        $stats['finished'] = true;
        self::set_progress( array( 'cursor' => 0, 'phase' => 'idle', 'updated_at' => $now, 'last_scan_at' => $now ) );

        self::log_event( 'scanner_scan_complete', Shieldforge_Logger::SEV_INFO, array(
            'scanned'  => $stats['scanned'],
            'added'    => $stats['added'],
            'changed'  => $stats['changed'],
            'missing'  => $stats['missing'],
            'malware'  => $stats['malware'],
        ) );

        return $stats;
    }

    /**
     * Severity for a "this file changed" event depends on where it changed.
     * A change inside wp-includes or wp-admin is highly suspicious; a change
     * inside wp-content/themes/X is normal during theme dev.
     */
    private static function sev_for_change( $source ) {
        if ( self::SOURCE_CORE === $source ) {
            return Shieldforge_Logger::SEV_HIGH;
        }
        return Shieldforge_Logger::SEV_WARN;
    }

    private static function find_and_log_missing( $sweep_started_at ) {
        global $wpdb;
        $table = self::files_table();
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user values use $wpdb->prepare().
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT id, path, file_size, source FROM {$table}
             WHERE updated_at < %s AND state = %s",
            $sweep_started_at, self::STATE_TRACKED
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( ! $rows ) {
            return 0;
        }
        $count = 0;
        foreach ( $rows as $r ) {
            self::log_event(
                Shieldforge_Logger::EVT_FILE_MISSING,
                self::sev_for_change( $r->source ),
                array(
                    'path'   => self::short_path( (string) $r->path ),
                    'size'   => (int) $r->file_size,
                    'source' => $r->source,
                )
            );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
            $wpdb->delete( $table, array( 'id' => (int) $r->id ), array( '%d' ) );
            $count++;
        }
        return $count;
    }

    /* ==================================================================
     *  Pattern integration
     * ================================================================== */

    /**
     * Run the pattern-based suspicious-code scan against a file. Logs each
     * hit as a separate MALWARE_SUSPECT event for later triage.
     *
     * Counts the number of pattern hits into $stats['malware'] (passed by ref).
     */
    public static function pattern_scan_file( $abs_path, $source, &$stats ) {
        if ( ! Shieldforge_Settings::get( 'scanner_pattern_enabled', true ) ) {
            return 0;
        }
        if ( ! class_exists( 'Shieldforge_Pattern_Scanner' ) ) {
            require_once SHIELDFORGE_PATH . 'includes/class-shieldforge-pattern-scanner.php';
        }
        $hits = Shieldforge_Pattern_Scanner::scan_file( $abs_path );
        if ( empty( $hits ) ) {
            return 0;
        }
        foreach ( $hits as $hit ) {
            self::log_event(
                Shieldforge_Logger::EVT_MALWARE_SUSPECT,
                $hit['severity'],
                array(
                    'path'      => self::short_path( $abs_path ),
                    'rule'      => $hit['rule'],
                    'snippet'   => $hit['snippet'],
                    'line'      => $hit['line'],
                    'source'    => $source,
                )
            );
        }
        $stats['malware'] += count( $hits );
        return count( $hits );
    }

    /* ==================================================================
     *  Findings — read API for the admin UI
     * ================================================================== */

    /**
     * Latest scanner findings as a flat list (newest first).
     * Used by the admin Scanner tab and the dashboard widget.
     *
     * @param int $hours look-back window
     * @param int $limit
     */
    public static function recent_findings( $hours = 168, $limit = 200 ) {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'log' );
        $since = gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS * (int) $hours );
        $types = "('" . implode( "','", array(
            Shieldforge_Logger::EVT_FILE_ADDED,
            Shieldforge_Logger::EVT_FILE_CHANGED,
            Shieldforge_Logger::EVT_FILE_MISSING,
            Shieldforge_Logger::EVT_MALWARE_SUSPECT,
        ) ) . "')";
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; trusted code-constructed table name and event-type list (built from internal constants, not user input); user values use $wpdb->prepare() placeholders.
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT id, event_type, severity, created_at, context
             FROM {$table}
             WHERE created_at >= %s AND event_type IN {$types}
             ORDER BY id DESC LIMIT %d",
            $since, (int) $limit
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        foreach ( (array) $rows as $r ) {
            $r->ctx = json_decode( (string) $r->context, true );
            if ( ! is_array( $r->ctx ) ) {
                $r->ctx = array();
            }
        }
        return (array) $rows;
    }

    /**
     * Aggregate counts per event_type for last $hours.
     */
    public static function findings_summary( $hours = 24 ) {
        $since = gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS * (int) $hours );
        return array(
            'added'   => Shieldforge_Logger::count_since( $since, array( 'event_type' => Shieldforge_Logger::EVT_FILE_ADDED ) ),
            'changed' => Shieldforge_Logger::count_since( $since, array( 'event_type' => Shieldforge_Logger::EVT_FILE_CHANGED ) ),
            'missing' => Shieldforge_Logger::count_since( $since, array( 'event_type' => Shieldforge_Logger::EVT_FILE_MISSING ) ),
            'malware' => Shieldforge_Logger::count_since( $since, array( 'event_type' => Shieldforge_Logger::EVT_MALWARE_SUSPECT ) ),
        );
    }

    public static function status() {
        $progress = self::get_progress();
        return array(
            'enabled'         => (bool) Shieldforge_Settings::get( 'scanner_enabled', true ),
            'pattern_enabled' => (bool) Shieldforge_Settings::get( 'scanner_pattern_enabled', true ),
            'tracked_files'   => self::baseline_count(),
            'phase'           => isset( $progress['phase'] ) ? $progress['phase'] : 'idle',
            'cursor'          => isset( $progress['cursor'] ) ? (int) $progress['cursor'] : 0,
            'last_scan_at'    => isset( $progress['last_scan_at'] ) ? $progress['last_scan_at'] : null,
            'last_baseline_at' => isset( $progress['last_baseline_at'] ) ? $progress['last_baseline_at'] : null,
            'next_run'        => wp_next_scheduled( self::CRON_HOOK ),
        );
    }

    /* ==================================================================
     *  Whitelist / acknowledge actions
     * ================================================================== */

    /**
     * Whitelist the path so future ADDED / CHANGED events are suppressed.
     * Refreshes the stored hash to "now" so the next scan won't re-fire.
     */
    public static function whitelist_path( $abs_path ) {
        global $wpdb;
        $table = self::files_table();
        $hash  = self::path_hash( $abs_path );
        $row   = self::fingerprint( $abs_path );
        $now   = current_time( 'mysql', 1 );

        $existing = self::get_baseline_row( $abs_path );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        if ( $existing ) {
            $wpdb->update(
                $table,
                array(
                    'state'      => self::STATE_WHITELISTED,
                    'file_hash'  => $row ? $row['hash'] : $existing->file_hash,
                    'file_size'  => $row ? $row['size'] : (int) $existing->file_size,
                    'file_mtime' => $row ? $row['mtime'] : (int) $existing->file_mtime,
                    'updated_at' => $now,
                ),
                array( 'id' => (int) $existing->id ),
                array( '%s', '%s', '%d', '%d', '%s' ),
                array( '%d' )
            );
        } elseif ( $row ) {
            $wpdb->insert(
                $table,
                array(
                    'path_hash'   => $hash,
                    'path'        => $abs_path,
                    'file_hash'   => $row['hash'],
                    'file_size'   => $row['size'],
                    'file_mtime'  => $row['mtime'],
                    'source'      => self::source_for_path( $abs_path ),
                    'state'       => self::STATE_WHITELISTED,
                    'baseline_at' => $now,
                    'updated_at'  => $now,
                ),
                array( '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s' )
            );
        } else {
            return false;
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return true;
    }

    public static function unwhitelist_path( $abs_path ) {
        global $wpdb;
        $row = self::get_baseline_row( $abs_path );
        if ( ! $row ) {
            return false;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        $wpdb->update(
            self::files_table(),
            array( 'state' => self::STATE_TRACKED ),
            array( 'id' => (int) $row->id ),
            array( '%s' ),
            array( '%d' )
        );
        return true;
    }

    /**
     * "Forget" an alert: drop its baseline row entirely so the file is
     * re-discovered on the next scan as ADDED. Useful when the admin wants
     * to re-baseline a single file after a legitimate edit.
     */
    public static function rebaseline_path( $abs_path ) {
        global $wpdb;
        $row = self::fingerprint( $abs_path );
        $now = current_time( 'mysql', 1 );
        if ( ! $row ) {
            // file gone — drop baseline row
            $existing = self::get_baseline_row( $abs_path );
            if ( $existing ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
                $wpdb->delete( self::files_table(), array( 'id' => (int) $existing->id ), array( '%d' ) );
                return true;
            }
            return false;
        }
        self::upsert_baseline( $abs_path, $row['hash'], $row['size'], $row['mtime'], self::source_for_path( $abs_path ), $now );
        return true;
    }

    private static function source_for_path( $abs_path ) {
        $abs_path = str_replace( '\\', '/', $abs_path );
        if ( defined( 'WP_PLUGIN_DIR' ) && 0 === strpos( $abs_path, str_replace( '\\', '/', WP_PLUGIN_DIR ) ) ) {
            return self::SOURCE_PLUGIN;
        }
        if ( defined( 'WPMU_PLUGIN_DIR' ) && 0 === strpos( $abs_path, str_replace( '\\', '/', WPMU_PLUGIN_DIR ) ) ) {
            return self::SOURCE_MU_PLUGIN;
        }
        if ( false !== strpos( $abs_path, '/wp-content/themes/' ) ) {
            return self::SOURCE_THEME;
        }
        if ( false !== strpos( $abs_path, '/wp-admin/' ) || false !== strpos( $abs_path, '/wp-includes/' ) ) {
            return self::SOURCE_CORE;
        }
        return self::SOURCE_ROOT;
    }

    /* ==================================================================
     *  Low-level baseline helpers
     * ================================================================== */

    /**
     * Compute hash + size + mtime for a single file. Returns null if
     * unreadable / above the size cap.
     */
    public static function fingerprint( $abs_path ) {
        if ( ! is_file( $abs_path ) || ! is_readable( $abs_path ) ) {
            return null;
        }
        $size = @filesize( $abs_path );
        $cap  = (int) Shieldforge_Settings::get( 'scanner_max_file_kb', 4096 ) * 1024;
        if ( false === $size || $size > $cap ) {
            return null;
        }
        $hash = @hash_file( 'sha256', $abs_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- race window between the is_readable() check above and this read (file can vanish/become unreadable); failure is handled via the null check below.
        if ( ! $hash ) {
            return null;
        }
        $mtime = (int) @filemtime( $abs_path );
        return array( 'hash' => $hash, 'size' => (int) $size, 'mtime' => $mtime );
    }

    private static function upsert_baseline( $abs_path, $hash, $size, $mtime, $source, $now ) {
        global $wpdb;
        $table     = self::files_table();
        $path_hash = self::path_hash( $abs_path );

        $existing = self::get_baseline_row( $abs_path );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        if ( $existing ) {
            $wpdb->update(
                $table,
                array(
                    'file_hash'  => $hash,
                    'file_size'  => $size,
                    'file_mtime' => $mtime,
                    'source'     => $source,
                    'updated_at' => $now,
                ),
                array( 'id' => (int) $existing->id ),
                array( '%s', '%d', '%d', '%s', '%s' ),
                array( '%d' )
            );
        } else {
            $wpdb->insert(
                $table,
                array(
                    'path_hash'   => $path_hash,
                    'path'        => $abs_path,
                    'file_hash'   => $hash,
                    'file_size'   => $size,
                    'file_mtime'  => $mtime,
                    'source'      => $source,
                    'state'       => self::STATE_TRACKED,
                    'baseline_at' => $now,
                    'updated_at'  => $now,
                ),
                array( '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s' )
            );
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }

    private static function touch_baseline( $abs_path, $now ) {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        $wpdb->update(
            self::files_table(),
            array( 'updated_at' => $now ),
            array( 'path_hash' => self::path_hash( $abs_path ) ),
            array( '%s' ),
            array( '%s' )
        );
    }

    public static function get_baseline_row( $abs_path ) {
        global $wpdb;
        $table = self::files_table();
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user value uses $wpdb->prepare() placeholder.
        return $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$table} WHERE path_hash = %s LIMIT 1",
            self::path_hash( $abs_path )
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }

    public static function path_hash( $abs_path ) {
        return sha1( str_replace( '\\', '/', $abs_path ) );
    }

    /**
     * Trim absolute path to ABSPATH-relative for log/UI display.
     * Keeps event payloads compact and makes them portable across moves.
     */
    public static function short_path( $abs_path ) {
        $abs_path = str_replace( '\\', '/', $abs_path );
        $base     = str_replace( '\\', '/', untrailingslashit( ABSPATH ) );
        if ( 0 === strpos( $abs_path, $base ) ) {
            return ltrim( substr( $abs_path, strlen( $base ) ), '/' );
        }
        return $abs_path;
    }

    /**
     * Resolve a short_path (as stored in event context) back to absolute.
     * Used by the admin actions (whitelist / re-baseline).
     */
    public static function resolve_path( $maybe_short ) {
        $maybe_short = (string) $maybe_short;
        if ( '' === $maybe_short ) {
            return '';
        }
        // Already absolute? trust it (real-paths still get validated downstream).
        if ( '/' === substr( $maybe_short, 0, 1 ) || preg_match( '/^[A-Za-z]:[\\\\\/]/', $maybe_short ) ) {
            return $maybe_short;
        }
        return rtrim( ABSPATH, '/\\' ) . '/' . ltrim( $maybe_short, '/' );
    }

    /* ==================================================================
     *  Progress / event helpers
     * ================================================================== */

    public static function get_progress() {
        $opt = is_multisite()
            ? get_site_option( self::PROGRESS_OPT, array() )
            : get_option( self::PROGRESS_OPT, array() );
        return is_array( $opt ) ? $opt : array();
    }

    public static function set_progress( array $progress ) {
        $merged = array_merge( self::get_progress(), $progress );
        if ( is_multisite() ) {
            update_site_option( self::PROGRESS_OPT, $merged );
        } else {
            update_option( self::PROGRESS_OPT, $merged );
        }
    }

    private static function log_event( $event_type, $severity, array $context ) {
        Shieldforge_Logger::log( $event_type, array(
            'severity' => $severity,
            'context'  => $context,
        ) );
    }

    /**
     * Reset everything (baseline + progress). Used by the Tools tab "rebuild
     * baseline" action and by uninstall.
     */
    public static function reset() {
        global $wpdb;
        // self::files_table() returns a trusted internal table name ($wpdb->prefix), never user input.
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $wpdb->query( "TRUNCATE TABLE " . self::files_table() );
        if ( is_multisite() ) {
            delete_site_option( self::PROGRESS_OPT );
        } else {
            delete_option( self::PROGRESS_OPT );
        }
    }
}
