<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall\Blocklist;

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

use SiteFort\Core\Database\SchemaInspector;
use SiteFort\Core\Installer;
use SiteFort\Includes\DataDirectoryProtection;
use SiteFort\Includes\IpUtils;
use SiteFort\Includes\SettingsStore;
use SiteFort\Modules\Firewall\ExtendedProtection\Manager as ExtendedProtectionManager;
use SiteFort\Modules\Firewall\ExtendedProtection\WafRuntimeStore;

if ( ! class_exists( WafRuntimeStore::class, false ) ) {
    require_once dirname( __DIR__ ) . '/ExtendedProtection/WafRuntimeStore.php';
}

/**
 * Community IP blocklist orchestrator.
 *
 * Manages synchronization from multiple threat intelligence providers
 * (crowd-sourced network intel via the SiteFort Intel cloud) and
 * provides a fast lookup interface for checking whether an IP address
 * is on the community blocklist.
 *
 * Blocklist entries are stored as individual rows in the dedicated
 * wp_sitefort_blocklist table. Lookups use a multi-layer cache:
 *
 * 1. Per-request PHP memory ($lookup_cache property)
 * 2. WordPress object cache (wp_cache, persists across requests
 *    when Redis/Memcached is available)
 * 3. Database fallback (single query to build the cache)
 *
 * The cache holds a hash set for O(1) exact IP checks and
 * pre-computed numeric ranges for fast CIDR matching.
 *
 */
class BlocklistService {
    private const CRON_SCHEDULE = 'sixhours';

    /**
     * Database table name (without prefix).
     */
    private const TABLE = 'sitefort_blocklist';

    private const RANGE_TABLE = 'sitefort_blocklist_ranges';

    /**
     * SettingsStore key for lightweight sync metadata.
     */
    private const STATE_KEY_META = 'community_blocklist_meta';
    private const STATE_KEY_WAF_SIGNATURE = 'waf_signature';
    private const WAF_BLOCKLIST_FILE = 'waf-blocklist.php';

    /**
     * Maximum number of entries to store.
     */
    private const MAX_ENTRIES = 10000;

    private const MAX_TABLE_IDENTIFIER_LENGTH = 64;

    /**
     * Cron hook name for scheduled sync.
     */
    public const CRON_HOOK = 'sitefort_blocklist_sync';

    /**
     * WordPress object cache group.
     */
    private const CACHE_GROUP = 'sitefort_blocklist';

    /**
     * WordPress object cache key for the lookup structures.
     */
    private const CACHE_KEY = 'lookup';

    /**
     * Option key used as a sync mutex.
     */
    private const SYNC_LOCK_OPTION = 'sitefort_blocklist_sync_lock';

    /**
     * Maximum age for a sync lock before it is considered stale.
     */
    private const SYNC_LOCK_TTL = 900;

    /**
     * Firewall settings array.
     *
     * @var array
     */
    private array $settings;

    /**
     * Per-request cache of the lookup structures.
     * Avoids repeated wp_cache_get calls within the same request.
     *
     * @var array{ips: array<string, true>, ipv4_cidrs: array, ipv6_cidrs: array}|null
     */
    private ?array $lookup_cache = null;

    /**
     * Whether the table existence has been verified this request.
     *
     * @var bool
     */
    private static bool $table_ready = false;
    private static bool $range_table_ready = false;

    private static bool $table_missing_logged = false;
    private static bool $range_table_missing_logged = false;

    private static ?string $table_context_prefix = null;

    /**
     * @param array $settings Firewall settings.
     */
    public function __construct( array $settings ) {
        $this->settings = $settings;
    }

    /**
     * Run a full sync from the SiteFort Intel cloud.
     *
     * Fetches aggregated threat intelligence from the cloud feed,
     * replaces all rows in the blocklist table, and refreshes the
     * pre-WordPress lookup payload.
     *
     * @return array{total_entries: int, errors: string[], synced_at: int}
     */
    public function sync(): array {
        if ( ! $this->acquire_sync_lock() ) {
            $stats = $this->get_stats();

            return [
                'total_entries' => (int) ( $stats['entry_count'] ?? 0 ),
                'errors'        => [ 'Blocklist sync already in progress.' ],
                'synced_at'     => (int) ( $stats['last_sync'] ?? 0 ),
            ];
        }

        try {
            if ( ! self::ensure_table() ) {
                return [
                    'total_entries' => 0,
                    'errors'        => [ 'Community blocklist storage is unavailable until plugin storage is provisioned for this site.' ],
                    'synced_at'     => 0,
                ];
            }

            $errors      = [];
            $provider    = new CloudIntelProvider();
            $all_entries = $this->normalize_entries( $provider->fetch() );

            // Separate IPs and CIDRs.
            $ips   = [];
            $cidrs = [];
            foreach ( $all_entries as $entry ) {
                if ( ! is_string( $entry ) || $entry === '' ) {
                    continue;
                }
                if ( str_contains( $entry, '/' ) ) {
                    $cidrs[] = $entry;
                } else {
                    $ips[] = $entry;
                }
            }

            // Cap total entries (prefer keeping all CIDRs, trim IPs).
            $total = count( $ips ) + count( $cidrs );
            if ( $total > self::MAX_ENTRIES ) {
                $ips = array_slice( $ips, 0, self::MAX_ENTRIES - count( $cidrs ) );
            }

            global $wpdb;
            $table = self::blocklist_table_name();
            $now   = time();

            if ( empty( $ips ) && empty( $cidrs ) ) {
                $table_identifier = self::table_identifier( $table );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Counts rows in a plugin-owned blocklist table.
                $existing_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_identifier}" );
                $meta           = SettingsStore::get_field( SettingsStore::COL_STATE, self::STATE_KEY_META, [] );
                if ( ! is_array( $meta ) ) {
                    $meta = [];
                }
                $meta['errors'] = [ 'Blocklist sync returned no valid entries; keeping last known blocklist.' ];
                $meta['last_attempt'] = $now;
                SettingsStore::set_field( SettingsStore::COL_STATE, self::STATE_KEY_META, $meta );
                if ( ExtendedProtectionManager::should_use_managed_runtime_storage() ) {
                    $this->refresh_managed_runtime_artifacts_from_table( $now );
                } else {
                    WafRuntimeStore::delete( WafRuntimeStore::KEY_BLOCKLIST_META );
                }

                return [
                    'total_entries' => $existing_count,
                    'errors'        => $meta['errors'],
                    'synced_at'     => (int) ( $meta['last_sync'] ?? 0 ),
                ];
            }

            $sync_result = $this->replace_table_atomically( $table, $ips, $cidrs, $now );
            if ( ! empty( $sync_result['fallback_error'] ) ) {
                $errors[] = (string) $sync_result['fallback_error'];
            }

            if ( empty( $sync_result['success'] ) ) {
                return [
                    'total_entries' => 0,
                    'errors'        => [ (string) ( $sync_result['message'] ?? 'Failed to refresh community blocklist rows.' ) ],
                    'synced_at'     => 0,
                ];
            }

            // Invalidate all caches.
            $this->lookup_cache = null;
            self::flush_cache();

            // Store lightweight sync metadata in the state column.
            $artifact_signature = self::build_waf_artifact_signature( $ips, $cidrs );
            $meta = [
                'last_sync'      => $now,
                'total_entries'  => count( $ips ) + count( $cidrs ),
                'ip_count'       => count( $ips ),
                'cidr_count'     => count( $cidrs ),
                'errors'         => $errors,
                self::STATE_KEY_WAF_SIGNATURE => $artifact_signature,
            ];
            SettingsStore::set_field( SettingsStore::COL_STATE, self::STATE_KEY_META, $meta );
            if ( ExtendedProtectionManager::should_use_managed_runtime_storage() ) {
                $runtime_result = $this->refresh_managed_runtime_artifacts_from_entries( $ips, $cidrs, $artifact_signature, $now );
                if ( empty( $runtime_result['success'] ) ) {
                    $errors[] = (string) ( $runtime_result['message'] ?? 'Failed to refresh mysqli blocklist runtime storage.' );
                } elseif ( ! empty( $runtime_result['fallback_error'] ) ) {
                    $errors[] = (string) $runtime_result['fallback_error'];
                }
            } else {
                WafRuntimeStore::delete( WafRuntimeStore::KEY_BLOCKLIST_META );
            }
            if ( $meta['errors'] !== $errors ) {
                $meta['errors'] = $errors;
                SettingsStore::set_field( SettingsStore::COL_STATE, self::STATE_KEY_META, $meta );
            }

            // Refresh the file artifact used by filesystem-backed bootstrap lookups.
            if ( ! $this->write_waf_blocklist_file( $ips, $cidrs, $artifact_signature ) ) {
                if ( ExtendedProtectionManager::should_use_managed_runtime_storage() ) {
                    $errors[] = 'Mysqli runtime storage will continue serving the community blocklist from the database, but the file cache copy could not be refreshed.';
                } else {
                    $errors[] = 'Failed to refresh the pre-WordPress community blocklist cache. Runtime lookups will continue protecting the site until the cache is rebuilt.';
                }
                $meta['errors'] = $errors;
                SettingsStore::set_field( SettingsStore::COL_STATE, self::STATE_KEY_META, $meta );
            }

            $this->refresh_waf_policy_config();

            return [
                'total_entries' => $meta['total_entries'],
                'errors'        => $errors,
                'synced_at'     => $now,
            ];
        } finally {
            $this->release_sync_lock();
        }
    }

    /**
     * Check if an IP address is on the community blocklist.
     *
     * Lookup priority:
     * 1. Per-request memory cache (zero cost on repeat calls)
     * 2. WordPress object cache (zero DB queries with Redis/Memcached)
     * 3. Single DB query to build the cache (fallback)
     *
     * @param string $ip The IP address to check.
     * @return bool True if the IP is blocked.
     */
    public function is_blocked( string $ip ): bool {
        if ( empty( $this->settings['blocklist_enabled'] ) ) {
            return false;
        }

        $structures = $this->get_lookup_structures();
        if ( $structures === null ) {
            return false;
        }

        // 1. O(1) exact IP check via hash set.
        if ( isset( $structures['ips'][ $ip ] ) ) {
            return true;
        }

        // 2. IPv4 CIDR numeric range check.
        $ip_long = ip2long( $ip );
        if ( $ip_long !== false ) {
            foreach ( $structures['ipv4_cidrs'] as $range ) {
                if ( $ip_long >= $range[0] && $ip_long <= $range[1] ) {
                    return true;
                }
            }
        }

        // 3. IPv6 CIDR fallback.
        foreach ( $structures['ipv6_cidrs'] as $cidr ) {
            if ( IpUtils::ip_in_range( $ip, $cidr ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get statistics about the current blocklist.
     *
     * @return array{entry_count: int, last_sync: int|null, errors: string[]}
     */
    public function get_stats(): array {
        $meta = SettingsStore::get_field( SettingsStore::COL_STATE, self::STATE_KEY_META, [] );
        if ( ! is_array( $meta ) ) {
            $meta = [];
        }

        if ( ! self::ensure_table() ) {
            return [
                'entry_count' => 0,
                'last_sync'   => $meta['last_sync'] ?? null,
                'errors'      => array_merge(
                    (array) ( $meta['errors'] ?? [] ),
                    [ 'Community blocklist storage is unavailable until plugin storage is provisioned for this site.' ]
                ),
            ];
        }

        global $wpdb;
        $table = self::table_identifier( self::blocklist_table_name() );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Counts rows in a plugin-owned blocklist table.
        $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );

        return [
            'entry_count' => $count,
            'last_sync'   => $meta['last_sync'] ?? null,
            'errors'      => $meta['errors'] ?? [],
        ];
    }

    /**
     * Flush the blocklist lookup cache.
     *
     * Called when firewall settings change (e.g. blocklist_enabled
     * toggled) via the sitefort_settings_saved_firewall hook,
     * and internally after every sync.
     */
    public static function flush_cache(): void {
        wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP );
    }

    /**
     * Schedule the blocklist sync cron event (every 6 hours).
     */
    public function schedule_sync(): void {
        self::ensure_sync_schedule_registered();

        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            $result = wp_schedule_event( time(), self::CRON_SCHEDULE, self::CRON_HOOK, [], true );
            if ( is_wp_error( $result ) ) {
                sitefort_log( sprintf( 'SiteFort Firewall: failed to schedule blocklist sync: %s', $result->get_error_message() ) );
            }
        }
    }

    /**
     * Unschedule the blocklist sync cron event.
     */
    public function unschedule_sync(): void {
        wp_clear_scheduled_hook( self::CRON_HOOK );
        self::clear_waf_artifact();
    }

    public static function get_waf_artifact_signature(): string {
        $meta = SettingsStore::get_field( SettingsStore::COL_STATE, self::STATE_KEY_META, [] );
        if ( ! is_array( $meta ) ) {
            return '';
        }

        return sanitize_text_field( (string) ( $meta[ self::STATE_KEY_WAF_SIGNATURE ] ?? '' ) );
    }

    public static function clear_waf_artifact(): void {
        $meta = SettingsStore::get_field( SettingsStore::COL_STATE, self::STATE_KEY_META, [] );
        if ( is_array( $meta ) && array_key_exists( self::STATE_KEY_WAF_SIGNATURE, $meta ) ) {
            unset( $meta[ self::STATE_KEY_WAF_SIGNATURE ] );
            SettingsStore::set_field( SettingsStore::COL_STATE, self::STATE_KEY_META, $meta );
        }
        WafRuntimeStore::delete( WafRuntimeStore::KEY_BLOCKLIST_META );
        self::clear_range_table();

        $file = self::get_waf_blocklist_file_path();
        if ( is_file( $file ) ) {
            wp_delete_file( $file );
        }
    }

    public static function register_cron_schedule( array $schedules ): array {
        if ( ! isset( $schedules[ self::CRON_SCHEDULE ] ) ) {
            $schedules[ self::CRON_SCHEDULE ] = [
                'interval' => 21600,
                'display'  => 'Every 6 Hours',
            ];
        }

        return $schedules;
    }

    /**
     * Get (or build) the cached lookup structures.
     *
     * Returns an array with three keys:
     *   - ips: hash set (IP => true) for O(1) exact checks
     *   - ipv4_cidrs: array of [start, end] numeric pairs
     *   - ipv6_cidrs: array of CIDR strings for IpUtils fallback
     *
     * Cache layers:
     *   1. $this->lookup_cache (per-request PHP memory)
     *   2. wp_cache (persists across requests with Redis/Memcached)
     *   3. Single DB query (fallback, builds and populates caches)
     *
     * @return array|null The lookup structures, or null if empty.
     */
    private function get_lookup_structures(): ?array {
        // 1. Per-request memory.
        if ( $this->lookup_cache !== null ) {
            return $this->lookup_cache;
        }

        // 2. WordPress object cache.
        $cached = wp_cache_get( self::CACHE_KEY, self::CACHE_GROUP );
        if ( is_array( $cached ) && isset( $cached['ips'] ) ) {
            $this->lookup_cache = $cached;
            return $cached;
        }

        // 3. Build from database.
        if ( ! self::ensure_table() ) {
            return null;
        }

        global $wpdb;
        $table = self::table_identifier( self::blocklist_table_name() );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows  = $wpdb->get_results(
            "SELECT `host`, `type` FROM {$table}",
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        if ( empty( $rows ) ) {
            return null;
        }

        $structures = [
            'ips'         => [],
            'ipv4_cidrs'  => [],
            'ipv6_cidrs'  => [],
        ];

        foreach ( $rows as $row ) {
            $host = $row['host'];

            if ( $row['type'] === 'cidr' ) {
                [ $subnet, $bits ] = explode( '/', $host, 2 );
                $bits        = (int) $bits;
                $subnet_long = ip2long( $subnet );

                if ( $subnet_long !== false && $bits >= 0 && $bits <= 32 ) {
                    $mask  = $bits === 0 ? 0 : ( ( ~0 << ( 32 - $bits ) ) & 0xFFFFFFFF );
                    $start = $subnet_long & $mask;
                    $end   = $start | ( ~$mask & 0xFFFFFFFF );
                    $structures['ipv4_cidrs'][] = [ $start, $end ];
                } else {
                    $structures['ipv6_cidrs'][] = $host;
                }
            } else {
                $structures['ips'][ $host ] = true;
            }
        }

        // Store in wp_cache (no expiry; invalidated on sync).
        wp_cache_set( self::CACHE_KEY, $structures, self::CACHE_GROUP );
        $this->lookup_cache = $structures;

        return $structures;
    }

    /**
     * Batch-insert entries into the blocklist table.
     *
     * @param string $table Raw fully-qualified table name.
     * @param array  $entries Array of host strings (IPs or CIDRs).
     * @param string $type 'ip' or 'cidr'.
     * @param int    $now Unix timestamp.
     */
    private function batch_insert( string $table, array $entries, string $type, int $now ): int {
        global $wpdb;
        $table_identifier = self::table_identifier( $table );
        $inserted_total = 0;

        foreach ( array_chunk( $entries, 100 ) as $chunk ) {
            $values       = [];
            $placeholders = [];

            foreach ( $chunk as $host ) {
                $placeholders[] = '(%s, %s, %s, %d)';
                $values[]       = $host;
                $values[]       = $type;
                $values[]       = 'cloud';
                $values[]       = $now;
            }

            if ( ! empty( $placeholders ) ) {
                $sql = "INSERT IGNORE INTO {$table_identifier} (`host`, `type`, `source`, `created_at`) VALUES "
                     . implode( ', ', $placeholders );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- Executes the prepared insert against a plugin-owned blocklist table.
                $result = $wpdb->query( $wpdb->prepare( $sql, ...$values ) );
                if ( $result !== false ) {
                    $inserted_total += (int) $result;
                }
            }
        }

        return $inserted_total;
    }

    /**
     * Replace the live blocklist table using an atomic shadow-table swap when
     * possible. Falls back to transaction-backed delete+reload only when the
     * live storage engine is transactional.
     *
     * @param string   $table Raw fully-qualified live table name.
     * @param string[] $ips
     * @param string[] $cidrs
     * @return array{success: bool, message?: string, fallback_error?: string}
     */
    private function replace_table_atomically( string $table, array $ips, array $cidrs, int $now ): array {
        global $wpdb;

        $table = self::validate_table_name( $table );
        $suffix = substr( md5( uniqid( '', true ) ), 0, 12 );
        $shadow_table = self::temporary_table_name( $table, 'tmp', $suffix );
        $old_table = self::temporary_table_name( $table, 'old', $suffix );
        $table_identifier = self::table_identifier( $table );
        $shadow_identifier = self::table_identifier( $shadow_table );
        $old_identifier = self::table_identifier( $old_table );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Creates a shadow copy of a validated plugin-owned table for atomic swap.
        $shadow_created = ( $wpdb->query( "CREATE TABLE {$shadow_identifier} LIKE {$table_identifier}" ) !== false );

        if ( $shadow_created ) {
            $inserted = 0;
            $inserted += $this->batch_insert( $shadow_table, $ips, 'ip', $now );
            $inserted += $this->batch_insert( $shadow_table, $cidrs, 'cidr', $now );

            if ( $inserted <= 0 ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cleans up the unused shadow blocklist table after a failed population step.
                $wpdb->query( "DROP TABLE IF EXISTS {$shadow_identifier}" );

                return [
                    'success' => false,
                    'message' => 'Failed to write refreshed blocklist rows.',
                ];
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Atomically swaps plugin-owned tables so readers see either the old or the new full blocklist.
            $swapped = ( $wpdb->query( "RENAME TABLE {$table_identifier} TO {$old_identifier}, {$shadow_identifier} TO {$table_identifier}" ) !== false );
            if ( $swapped ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Best-effort cleanup of the retired blocklist snapshot after a successful swap.
                $drop_old = $wpdb->query( "DROP TABLE IF EXISTS {$old_identifier}" );
                if ( $drop_old === false ) {
                    sitefort_log( 'SiteFort Firewall: community blocklist swap completed but the retired snapshot table could not be removed.' );
                }

                return [ 'success' => true ];
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cleans up the shadow table when the swap step is unavailable.
            $wpdb->query( "DROP TABLE IF EXISTS {$shadow_identifier}" );
        }

        $fallback_error = $wpdb->last_error;
        if ( ! $this->supports_transactional_reload( $table ) ) {
            return [
                'success' => false,
                'message' => 'Atomic table swap unavailable and transactional fallback is not supported on this storage engine. Keeping the last known blocklist.',
            ];
        }

        $fallback_result = $this->replace_table_in_transaction( $table, $ips, $cidrs, $now );
        if ( ! empty( $fallback_result['success'] ) ) {
            return [
                'success' => true,
                'fallback_error' => $fallback_error !== '' ? 'Atomic table swap unavailable; used transactional reload instead.' : '',
            ];
        }

        return [
            'success' => false,
            'message' => (string) ( $fallback_result['message'] ?? 'Failed to refresh community blocklist rows.' ),
        ];
    }

    /**
     * Fallback reload path for environments where CREATE TABLE LIKE / RENAME TABLE
     * is not available to the database user.
     *
     * @param string   $table Raw fully-qualified live table name.
     * @param string[] $ips
     * @param string[] $cidrs
     * @return array{success: bool, message?: string}
     */
    private function replace_table_in_transaction( string $table, array $ips, array $cidrs, int $now ): array {
        global $wpdb;

        $table_identifier = self::table_identifier( $table );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Opens a transaction for an atomic blocklist refresh when supported.
        $transaction_started = ( $wpdb->query( 'START TRANSACTION' ) !== false );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Clears a plugin-owned blocklist table before repopulating it in fallback mode.
        $delete_result = $wpdb->query( "DELETE FROM {$table_identifier} WHERE 1=1" );

        if ( $delete_result === false ) {
            if ( $transaction_started ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Rolls back the fallback blocklist refresh transaction.
                $wpdb->query( 'ROLLBACK' );
            }

            return [
                'success' => false,
                'message' => 'Failed to clear existing blocklist rows.',
            ];
        }

        $inserted = 0;
        $inserted += $this->batch_insert( $table, $ips, 'ip', $now );
        $inserted += $this->batch_insert( $table, $cidrs, 'cidr', $now );

        if ( $inserted <= 0 ) {
            if ( $transaction_started ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Rolls back the fallback blocklist refresh transaction.
                $wpdb->query( 'ROLLBACK' );
            }

            return [
                'success' => false,
                'message' => 'Failed to write refreshed blocklist rows.',
            ];
        }

        if ( $transaction_started ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Commits the fallback blocklist refresh transaction.
            $wpdb->query( 'COMMIT' );
        }

        return [ 'success' => true ];
    }

    /**
     * Determine whether the live blocklist table uses a transactional engine.
     *
     * The delete-and-reload fallback is only safe enough on transactional
     * engines where uncommitted deletes are not exposed to concurrent readers.
     */
    private function supports_transactional_reload( string $table ): bool {
        global $wpdb;

        $table_name = self::validate_table_name( $table );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads engine metadata for a plugin-owned table to choose a safe reload strategy.
        $status = $wpdb->get_row(
            $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $wpdb->esc_like( $table_name ) ),
            ARRAY_A
        );

        if ( ! is_array( $status ) ) {
            return false;
        }

        $engine = strtolower( (string) ( $status['Engine'] ?? '' ) );

        return in_array( $engine, [ 'innodb', 'xtradb' ], true );
    }

    /**
     * Write the blocklist as a PHP file for WAF-level blocking
     * (pre-WordPress). Uses atomic write (tmp + rename).
     *
     * @param string[] $ips  Exact IP addresses.
     * @param string[] $cidrs CIDR ranges.
     */
    private function write_waf_blocklist_file( array $ips, array $cidrs, string $signature ): bool {
        if ( ! DataDirectoryProtection::ensure_runtime_root() ) {
            return false;
        }

        $ip_set = [];
        foreach ( $ips as $ip ) {
            $ip_set[ $ip ] = true;
        }

        $config = [
            'ips'        => $ip_set,
            'cidrs'      => array_values( $cidrs ),
            'signature'  => $signature,
            'updated_at' => time(),
        ];

        $content  = "<?php\n";
        $content .= "// Auto-generated by SiteFort blocklist sync. Do not edit.\n";
        $content .= '// Last updated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Pre-WordPress bootstrap consumes this generated PHP array directly.
        $content .= 'return ' . var_export( $config, true ) . ";\n";

        // Atomic write: write to temp file, then rename.
        $file = self::get_waf_blocklist_file_path();
        $temp = $file . '.tmp.' . bin2hex( random_bytes( 4 ) );

        if ( @file_put_contents( $temp, $content, LOCK_EX ) === false ) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Atomic temp-file write for the pre-WordPress blocklist cache.
        @chmod( $temp, DataDirectoryProtection::get_runtime_file_mode() );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace keeps the bootstrap blocklist file consistent during reads.
        if ( ! @rename( $temp, $file ) ) {
            wp_delete_file( $temp );
            return false;
        }

        return true;
    }

    /**
     * Refresh the extended-protection config so bootstrap trusts the current
     * community blocklist artifact signature after each successful sync.
     */
    private function refresh_waf_policy_config(): void {
        $manager_class = '\SiteFort\Modules\Firewall\ExtendedProtection\Manager';
        $writer_class  = '\SiteFort\Modules\Firewall\ExtendedProtection\ConfigWriter';

        if ( ! class_exists( $manager_class ) ) {
            $manager_file = dirname( __DIR__ ) . '/ExtendedProtection/Manager.php';
            if ( is_file( $manager_file ) ) {
                require_once $manager_file;
            }
        }

        if ( ! class_exists( $writer_class ) ) {
            $writer_file = dirname( __DIR__ ) . '/ExtendedProtection/ConfigWriter.php';
            if ( is_file( $writer_file ) ) {
                require_once $writer_file;
            }
        }

        if ( ! class_exists( $manager_class ) || ! class_exists( $writer_class ) || ! method_exists( $manager_class, 'get_bootstrap_path' ) ) {
            return;
        }

        if ( ! is_file( $manager_class::get_bootstrap_path() ) ) {
            return;
        }

        $writer_class::write();
    }

    /**
     * Build a stable signature for the WAF blocklist artifact contents.
     *
     * @param string[] $ips
     * @param string[] $cidrs
     */
    private static function build_waf_artifact_signature( array $ips, array $cidrs ): string {
        $ips = array_values( array_unique( array_map( 'strval', $ips ) ) );
        $cidrs = array_values( array_unique( array_map( 'strval', $cidrs ) ) );
        sort( $ips, SORT_STRING );
        sort( $cidrs, SORT_STRING );

        return hash(
            'sha256',
            (string) wp_json_encode(
                [
                    'ips' => $ips,
                    'cidrs' => $cidrs,
                ]
            )
        );
    }

    /**
     * Ensure mysqli runtime blocklist metadata and CIDR ranges are
     * materialized from the canonical blocklist table.
     */
    public static function ensure_managed_runtime_artifacts_current(): bool {
        if ( ! ExtendedProtectionManager::should_use_managed_runtime_storage() ) {
            WafRuntimeStore::delete( WafRuntimeStore::KEY_BLOCKLIST_META );
            return true;
        }

        $result = ( new self( [] ) )->refresh_managed_runtime_artifacts_from_table( time() );

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

    /**
     * @return array{success: bool, message?: string, fallback_error?: string}
     */
    private function refresh_managed_runtime_artifacts_from_table( int $now ): array {
        if ( ! self::ensure_table() ) {
            return [
                'success' => false,
                'message' => 'Community blocklist storage is unavailable until plugin storage is provisioned for this site.',
            ];
        }

        global $wpdb;
        $table = self::table_identifier( self::blocklist_table_name() );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            "SELECT `host`, `type` FROM {$table}",
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $ips = [];
        $cidrs = [];
        foreach ( is_array( $rows ) ? $rows : [] as $row ) {
            $host = isset( $row['host'] ) ? (string) $row['host'] : '';
            if ( $host === '' ) {
                continue;
            }

            if ( (string) ( $row['type'] ?? '' ) === 'cidr' ) {
                $cidrs[] = $host;
            } else {
                $ips[] = $host;
            }
        }

        $signature = self::build_waf_artifact_signature( $ips, $cidrs );

        return $this->refresh_managed_runtime_artifacts_from_entries( $ips, $cidrs, $signature, $now );
    }

    /**
     * @param string[] $ips
     * @param string[] $cidrs
     * @return array{success: bool, message?: string, fallback_error?: string}
     */
    private function refresh_managed_runtime_artifacts_from_entries( array $ips, array $cidrs, string $signature, int $now ): array {
        $ips = array_values( array_unique( array_map( 'strval', $ips ) ) );
        $cidrs = array_values( array_unique( array_map( 'strval', $cidrs ) ) );

        if ( ! empty( $cidrs ) ) {
            $range_result = $this->replace_range_table_atomically( $cidrs, $now );
            if ( empty( $range_result['success'] ) ) {
                WafRuntimeStore::delete( WafRuntimeStore::KEY_BLOCKLIST_META );
                return $range_result;
            }
        } else {
            self::clear_range_table();
            $range_result = [ 'success' => true ];
        }

        $meta = SettingsStore::get_field( SettingsStore::COL_STATE, self::STATE_KEY_META, [] );
        if ( ! is_array( $meta ) ) {
            $meta = [];
        }
        $meta['last_sync'] = (int) ( $meta['last_sync'] ?? 0 );
        $meta['total_entries'] = count( $ips ) + count( $cidrs );
        $meta['ip_count'] = count( $ips );
        $meta['cidr_count'] = count( $cidrs );
        $meta[ self::STATE_KEY_WAF_SIGNATURE ] = $signature;
        SettingsStore::set_field( SettingsStore::COL_STATE, self::STATE_KEY_META, $meta );

        $stored = WafRuntimeStore::put(
            WafRuntimeStore::KEY_BLOCKLIST_META,
            [
                'signature'  => $signature,
                'ip_count'   => count( $ips ),
                'cidr_count' => count( $cidrs ),
                'updated_at' => $now,
            ]
        );

        if ( ! $stored ) {
            return [
                'success' => false,
                'message' => 'Failed to write mysqli blocklist runtime metadata.',
            ];
        }

        return $range_result;
    }

    /**
     * Replace the mysqli runtime CIDR index with packed binary ranges.
     *
     * Exact-IP lookups continue to use sitefort_blocklist; this table exists so
     * the pre-WordPress bootstrap can check CIDRs without decoding a JSON list.
     *
     * @param string[] $cidrs
     * @return array{success: bool, message?: string, fallback_error?: string}
     */
    private function replace_range_table_atomically( array $cidrs, int $now ): array {
        global $wpdb;

        if ( ! self::ensure_range_table() ) {
            return [
                'success' => false,
                'message' => 'Mysqli CIDR runtime index storage is unavailable until plugin storage is provisioned for this site.',
            ];
        }

        $table = self::blocklist_ranges_table_name();
        $suffix = substr( md5( uniqid( '', true ) ), 0, 12 );
        $shadow_table = self::temporary_table_name( $table, 'tmp', $suffix );
        $old_table = self::temporary_table_name( $table, 'old', $suffix );
        $table_identifier = self::table_identifier( $table );
        $shadow_identifier = self::table_identifier( $shadow_table );
        $old_identifier = self::table_identifier( $old_table );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Creates a shadow copy of a validated plugin-owned table for atomic swap.
        $shadow_created = ( $wpdb->query( "CREATE TABLE {$shadow_identifier} LIKE {$table_identifier}" ) !== false );

        if ( $shadow_created ) {
            $inserted = $this->batch_insert_ranges( $shadow_table, $cidrs, $now );
            if ( $inserted === null || ( ! empty( $cidrs ) && $inserted <= 0 ) ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cleans up the unused shadow blocklist range table after a failed population step.
                $wpdb->query( "DROP TABLE IF EXISTS {$shadow_identifier}" );

                return [
                    'success' => false,
                    'message' => 'Failed to write refreshed CIDR runtime index rows.',
                ];
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Atomically swaps plugin-owned tables so readers see either the old or the new full CIDR index.
            $swapped = ( $wpdb->query( "RENAME TABLE {$table_identifier} TO {$old_identifier}, {$shadow_identifier} TO {$table_identifier}" ) !== false );
            if ( $swapped ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Best-effort cleanup of the retired blocklist range snapshot after a successful swap.
                $wpdb->query( "DROP TABLE IF EXISTS {$old_identifier}" );
                SchemaInspector::flush_cache( $table );

                return [ 'success' => true ];
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cleans up the shadow table when the swap step is unavailable.
            $wpdb->query( "DROP TABLE IF EXISTS {$shadow_identifier}" );
        }

        $fallback_error = $wpdb->last_error;
        if ( ! $this->supports_transactional_reload( $table ) ) {
            return [
                'success' => false,
                'message' => 'Atomic CIDR index swap unavailable and transactional fallback is not supported on this storage engine. Keeping the last known CIDR runtime index.',
            ];
        }

        $fallback_result = $this->replace_range_table_in_transaction( $table, $cidrs, $now );
        if ( ! empty( $fallback_result['success'] ) ) {
            return [
                'success' => true,
                'fallback_error' => $fallback_error !== '' ? 'Atomic CIDR index swap unavailable; used transactional reload instead.' : '',
            ];
        }

        return [
            'success' => false,
            'message' => (string) ( $fallback_result['message'] ?? 'Failed to refresh CIDR runtime index rows.' ),
        ];
    }

    /**
     * @param string[] $cidrs
     */
    private function replace_range_table_in_transaction( string $table, array $cidrs, int $now ): array {
        global $wpdb;

        $table_identifier = self::table_identifier( $table );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Opens a transaction for an atomic CIDR index refresh when supported.
        $transaction_started = ( $wpdb->query( 'START TRANSACTION' ) !== false );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Clears a plugin-owned CIDR runtime index table before repopulating it in fallback mode.
        $delete_result = $wpdb->query( "DELETE FROM {$table_identifier} WHERE 1=1" );

        if ( $delete_result === false ) {
            if ( $transaction_started ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Rolls back the fallback CIDR index refresh transaction.
                $wpdb->query( 'ROLLBACK' );
            }

            return [
                'success' => false,
                'message' => 'Failed to clear existing CIDR runtime index rows.',
            ];
        }

        $inserted = $this->batch_insert_ranges( $table, $cidrs, $now );
        if ( $inserted === null || ( ! empty( $cidrs ) && $inserted <= 0 ) ) {
            if ( $transaction_started ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Rolls back the fallback CIDR index refresh transaction.
                $wpdb->query( 'ROLLBACK' );
            }

            return [
                'success' => false,
                'message' => 'Failed to write refreshed CIDR runtime index rows.',
            ];
        }

        if ( $transaction_started ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Commits the fallback CIDR index refresh transaction.
            $wpdb->query( 'COMMIT' );
        }

        return [ 'success' => true ];
    }

    /**
     * @param string[] $cidrs
     */
    private function batch_insert_ranges( string $table, array $cidrs, int $now ): ?int {
        global $wpdb;

        $table_identifier = self::table_identifier( $table );
        $inserted_total = 0;

        foreach ( array_chunk( $cidrs, 100 ) as $chunk ) {
            $values       = [];
            $placeholders = [];

            foreach ( $chunk as $cidr ) {
                if ( ! is_string( $cidr ) || $cidr === '' ) {
                    continue;
                }

                $range = self::cidr_to_packed_range( $cidr );
                if ( $range === null ) {
                    continue;
                }

                $placeholders[] = '(%d, %s, %s, %s, %d)';
                $values[]       = $range['family'];
                $values[]       = $range['start'];
                $values[]       = $range['end'];
                $values[]       = $cidr;
                $values[]       = $now;
            }

            if ( empty( $placeholders ) ) {
                continue;
            }

            $sql = "INSERT IGNORE INTO {$table_identifier} (`family`, `range_start`, `range_end`, `host`, `created_at`) VALUES "
                 . implode( ', ', $placeholders );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- Executes the prepared insert against a plugin-owned CIDR runtime index table.
            $result = $wpdb->query( $wpdb->prepare( $sql, ...$values ) );
            if ( $result === false ) {
                return null;
            }

            $inserted_total += (int) $result;
        }

        return $inserted_total;
    }

    /**
     * Convert a normalized CIDR to packed network-order start/end values.
     *
     * @return array{family:int,start:string,end:string}|null
     */
    private static function cidr_to_packed_range( string $cidr ): ?array {
        if ( ! str_contains( $cidr, '/' ) ) {
            return null;
        }

        [ $subnet, $bits_raw ] = explode( '/', $cidr, 2 );
        $subnet   = trim( $subnet );
        $bits_raw = trim( $bits_raw );

        if ( ! ctype_digit( $bits_raw ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
            return null;
        }

        $packed = @inet_pton( $subnet );
        if ( $packed === false ) {
            return null;
        }

        $length = strlen( $packed );
        if ( $length !== 4 && $length !== 16 ) {
            return null;
        }

        $family   = $length === 4 ? 4 : 6;
        $max_bits = $family === 4 ? 32 : 128;
        $bits     = (int) $bits_raw;
        if ( $bits < 0 || $bits > $max_bits ) {
            return null;
        }

        $start = '';
        $end   = '';
        $full_bytes = intdiv( $bits, 8 );
        $remaining_bits = $bits % 8;

        for ( $i = 0; $i < $length; $i++ ) {
            $byte = ord( $packed[ $i ] );
            if ( $i < $full_bytes ) {
                $mask = 0xFF;
            } elseif ( $i === $full_bytes && $remaining_bits > 0 ) {
                $mask = ( 0xFF << ( 8 - $remaining_bits ) ) & 0xFF;
            } else {
                $mask = 0;
            }

            $start .= chr( $byte & $mask );
            $end   .= chr( ( $byte & $mask ) | ( ~$mask & 0xFF ) );
        }

        return [
            'family' => $family,
            'start'  => $start,
            'end'    => $end,
        ];
    }

    private static function clear_range_table(): void {
        self::sync_table_context();

        try {
            $table_name = self::blocklist_ranges_table_name();
        } catch ( \InvalidArgumentException $e ) {
            return;
        }

        if ( ! SchemaInspector::table_exists( $table_name ) ) {
            return;
        }

        global $wpdb;
        $table = self::table_identifier( $table_name );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Clears plugin-owned mysqli CIDR index rows.
        $wpdb->query( "DELETE FROM {$table} WHERE 1=1" );
    }

    private static function get_waf_blocklist_file_path(): string {
        return WP_CONTENT_DIR . '/sitefort-data/' . self::WAF_BLOCKLIST_FILE;
    }

    /**
     * Check whether the blocklist table is available.
     */
    private static function ensure_table(): bool {
        self::sync_table_context();

        if ( self::$table_ready ) {
            return true;
        }

        global $wpdb;
        try {
            $table = self::blocklist_table_name();
        } catch ( \InvalidArgumentException $e ) {
            if ( ! self::$table_missing_logged ) {
                sitefort_log( 'SiteFort Firewall: community blocklist table identifier is invalid. Sync and lookups are paused until the database prefix is corrected.' );
                self::$table_missing_logged = true;
            }

            return false;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Lightweight existence check for a plugin-owned table during initialization.
        $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) );
        if ( $exists ) {
            self::$table_ready = true;
            return true;
        }

        if ( ! self::$table_missing_logged ) {
            sitefort_log( 'SiteFort Firewall: community blocklist table is unavailable. Sync and lookups are paused until plugin storage is provisioned for this site.' );
            self::$table_missing_logged = true;
        }

        return false;
    }

    /**
     * Check whether the mysqli runtime CIDR range table is available.
     */
    private static function ensure_range_table(): bool {
        self::sync_table_context();

        if ( self::$range_table_ready ) {
            return true;
        }

        try {
            $table = self::blocklist_ranges_table_name();
        } catch ( \InvalidArgumentException $e ) {
            if ( ! self::$range_table_missing_logged ) {
                sitefort_log( 'SiteFort Firewall: mysqli CIDR runtime table identifier is invalid. Runtime CIDR lookups are paused until the database prefix is corrected.' );
                self::$range_table_missing_logged = true;
            }

            return false;
        }

        if ( SchemaInspector::table_exists( $table ) ) {
            self::$range_table_ready = true;
            return true;
        }

        if ( class_exists( Installer::class ) && method_exists( Installer::class, 'ensure_blocklist_ranges_table_schema' ) ) {
            Installer::ensure_blocklist_ranges_table_schema();
            SchemaInspector::flush_cache( $table );
        }

        if ( SchemaInspector::table_exists( $table ) ) {
            self::$range_table_ready = true;
            return true;
        }

        if ( ! self::$range_table_missing_logged ) {
            sitefort_log( 'SiteFort Firewall: mysqli CIDR runtime table is unavailable. Runtime CIDR lookups are paused until plugin storage is provisioned for this site.' );
            self::$range_table_missing_logged = true;
        }

        return false;
    }

    /**
     * Reset memoized table state when execution switches to a different blog.
     */
    private static function sync_table_context(): void {
        global $wpdb;

        $prefix = isset( $wpdb->prefix ) ? (string) $wpdb->prefix : '';
        if ( self::$table_context_prefix === $prefix ) {
            return;
        }

        self::$table_context_prefix = $prefix;
        self::$table_ready                  = false;
        self::$range_table_ready            = false;
        self::$table_missing_logged         = false;
        self::$range_table_missing_logged   = false;
    }

    private static function ensure_sync_schedule_registered(): void {
        if ( ! has_filter( 'cron_schedules', [ self::class, 'register_cron_schedule' ] ) ) {
            add_filter( 'cron_schedules', [ self::class, 'register_cron_schedule' ] );
        }
    }

    /**
     * Acquire a sync lock for blocklist refreshes.
     */
    private function acquire_sync_lock(): bool {
        $now = time();

        if ( add_option( self::SYNC_LOCK_OPTION, $now, '', false ) ) {
            return true;
        }

        $existing = (int) get_option( self::SYNC_LOCK_OPTION, 0 );
        if ( $existing > 0 && ( $now - $existing ) < self::SYNC_LOCK_TTL ) {
            return false;
        }

        delete_option( self::SYNC_LOCK_OPTION );

        return add_option( self::SYNC_LOCK_OPTION, $now, '', false );
    }

    /**
     * Release the sync lock.
     */
    private function release_sync_lock(): void {
        delete_option( self::SYNC_LOCK_OPTION );
    }

    /**
     * Validate and normalize cloud blocklist entries.
     *
     * @param array<int,mixed> $entries
     * @return array<int,string>
     */
    private function normalize_entries( array $entries ): array {
        $normalized = [];

        foreach ( $entries as $entry ) {
            if ( ! is_string( $entry ) ) {
                continue;
            }

            $clean = $this->normalize_entry( $entry );
            if ( $clean === null ) {
                continue;
            }

            $normalized[ $clean ] = true;
        }

        return array_keys( $normalized );
    }

    /**
     * Validate a single IP/CIDR entry and return a canonical form.
     *
     * @return string|null
     */
    private function normalize_entry( string $entry ): ?string {
        $entry = trim( $entry );
        if ( $entry === '' ) {
            return null;
        }

        if ( str_contains( $entry, '/' ) ) {
            [ $subnet, $bits_raw ] = explode( '/', $entry, 2 );
            $subnet   = trim( $subnet );
            $bits_raw = trim( $bits_raw );

            if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) || ! ctype_digit( $bits_raw ) ) {
                return null;
            }

            $bits    = (int) $bits_raw;
            $maxBits = filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ? 32 : 128;
            if ( $bits < 0 || $bits > $maxBits ) {
                return null;
            }

            $packed = @inet_pton( $subnet );
            if ( $packed === false ) {
                return null;
            }

            $normalized_subnet = @inet_ntop( $packed );
            if ( $normalized_subnet === false ) {
                return null;
            }

            return $normalized_subnet . '/' . $bits;
        }

        if ( ! filter_var( $entry, FILTER_VALIDATE_IP ) ) {
            return null;
        }

        $packed = @inet_pton( $entry );
        if ( $packed === false ) {
            return null;
        }

        $normalized_ip = @inet_ntop( $packed );
        if ( $normalized_ip === false ) {
            return null;
        }

        return $normalized_ip;
    }

    private static function blocklist_table_name(): string {
        global $wpdb;

        return self::validate_table_name( (string) $wpdb->prefix . self::TABLE );
    }

    private static function blocklist_ranges_table_name(): string {
        global $wpdb;

        return self::validate_table_name( (string) $wpdb->prefix . self::RANGE_TABLE );
    }

    private static function temporary_table_name( string $table, string $kind, string $suffix ): string {
        $table = self::validate_table_name( $table );

        if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $kind ) || ! preg_match( '/^[A-Za-z0-9]+$/', $suffix ) ) {
            throw new \InvalidArgumentException( 'Invalid SiteFort temporary table identifier.' );
        }

        $marker = '_' . $kind . '_' . $suffix;
        if ( strlen( $table ) + strlen( $marker ) <= self::MAX_TABLE_IDENTIFIER_LENGTH ) {
            return self::validate_table_name( $table . $marker );
        }

        $hash        = substr( hash( 'sha256', $table . $marker ), 0, 12 );
        $marker      = '_' . $kind . '_' . $hash;
        $base_length = self::MAX_TABLE_IDENTIFIER_LENGTH - strlen( $marker );
        if ( $base_length < 1 ) {
            throw new \InvalidArgumentException( 'Invalid SiteFort temporary table identifier.' );
        }

        return self::validate_table_name( substr( $table, 0, $base_length ) . $marker );
    }

    private static function validate_table_name( string $table ): string {
        if (
            $table === ''
            || strlen( $table ) > self::MAX_TABLE_IDENTIFIER_LENGTH
            || ! preg_match( '/^[A-Za-z0-9_]+$/', $table )
        ) {
            throw new \InvalidArgumentException( 'Invalid SiteFort table identifier.' );
        }

        return $table;
    }

    private static function table_identifier( string $table ): string {
        $table = self::validate_table_name( $table );

        return '`' . str_replace( '`', '``', $table ) . '`';
    }
}
