<?php

declare(strict_types=1);

namespace SecurWP\Modules\Firewall\Blocklist;

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

use SecurWP\Includes\IpUtils;
use SecurWP\Includes\SettingsStore;

/**
 * Community IP blocklist orchestrator.
 *
 * Manages synchronization from multiple threat intelligence providers
 * (crowd-sourced network intel via the SecurWP 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_securwp_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 = 'securwp_blocklist';

    /**
     * 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 = 'securwp_blocklist_sync';

    /**
     * WordPress object cache group.
     */
    private const CACHE_GROUP = 'securwp_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 = 'securwp_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 $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 SecurWP Intel cloud.
     *
     * Fetches aggregated threat intelligence from the cloud feed,
     * replaces all rows in the blocklist table, and writes a PHP
     * file for WAF-level (pre-WordPress) blocking.
     *
     * @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 );

                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 );

            // Write PHP file for WAF-level blocking (pre-WordPress).
            if ( ! $this->write_waf_blocklist_file( $ips, $cidrs, $artifact_signature ) ) {
                $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 securwp_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 ) ) {
                securwp_log( sprintf( 'SecurWP 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 );
        }

        $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;
    }

    /**
     * One-time migration from the JSON blob in the state column
     * to the dedicated blocklist table.
     */
    public static function maybe_migrate_from_state(): void {
        if ( get_option( 'securwp_blocklist_migrated' ) ) {
            return;
        }

        $entries = SettingsStore::get_field( SettingsStore::COL_STATE, 'community_blocklist', [] );
        if ( ! is_array( $entries ) || empty( $entries ) ) {
            update_option( 'securwp_blocklist_migrated', '1', false );
            SettingsStore::delete_field( SettingsStore::COL_STATE, 'community_blocklist' );
            return;
        }

        global $wpdb;
        try {
            $table_name = self::blocklist_table_name();
        } catch ( \InvalidArgumentException $e ) {
            securwp_log( 'SecurWP Firewall: community blocklist migration skipped because the table identifier is invalid.' );
            return;
        }

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

        // Verify the table exists.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of a plugin-owned blocklist table before migration.
        $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) );
        if ( ! $exists ) {
            return; // Table not yet created; will migrate on next activation.
        }

        $now = time();

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

            foreach ( $chunk as $entry ) {
                if ( ! is_string( $entry ) || $entry === '' ) {
                    continue;
                }
                $type           = str_contains( $entry, '/' ) ? 'cidr' : 'ip';
                $placeholders[] = '(%s, %s, %s, %d)';
                $values[]       = $entry;
                $values[]       = $type;
                $values[]       = 'cloud';
                $values[]       = $now;
            }

            if ( ! empty( $placeholders ) ) {
                $sql = "INSERT IGNORE INTO {$table} (`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.
                $wpdb->query( $wpdb->prepare( $sql, ...$values ) );
            }
        }

        // Remove the JSON blob from the state column.
        SettingsStore::delete_field( SettingsStore::COL_STATE, 'community_blocklist' );

        update_option( 'securwp_blocklist_migrated', '1', false );
    }

    /**
     * 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 ) {
                    securwp_log( 'SecurWP 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 {
        $data_dir = WP_CONTENT_DIR . '/securwp-data';
        if ( ! is_dir( $data_dir ) ) {
            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 SecurWP 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, 0644 );

        // 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 = '\SecurWP\Modules\Firewall\ExtendedProtection\Manager';
        $writer_class  = '\SecurWP\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,
                ]
            )
        );
    }

    private static function get_waf_blocklist_file_path(): string {
        return WP_CONTENT_DIR . '/securwp-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 ) {
                securwp_log( 'SecurWP 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 ) {
            securwp_log( 'SecurWP 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;
    }

    /**
     * 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::$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 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 SecurWP 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 SecurWP 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 SecurWP table identifier.' );
        }

        return $table;
    }

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

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