<?php

declare(strict_types=1);

namespace SiteFort\Modules\Cloudflare;

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

use SiteFort\Includes\SecretManager;
use SiteFort\Includes\SettingsStore;
use SiteFort\Modules\Firewall\ProtectedNetworkGuard;

/**
 * Cloudflare policy manager.
 *
 * Maintains a stable desired-state model for SiteFort-managed
 * Cloudflare resources (lists, rules, fallback access rules) and handles
 * event-driven sync scheduling and retries.
 */
class Manager {

    public const CRON_HOOK = 'sitefort_cf_reconcile';
    public const AUTH_MODE_API_TOKEN = 'api_token';
    public const DOCS_URL = 'https://securewp.net/docs/firewall/#cloudflare-integration-guide';

    // Cloudflare permission-group short keys for the token-template deep link.
    // Kept in lockstep with audit_permissions(): any change here must match the scopes verified there.
    // NOTE: the dashboard displays this permission as "Account Filter Lists", but the underlying
    // API permission group is named "Account Rule Lists"; the template key follows the API name, not
    // the UI label. Verified 2026-07-15: 'account_filter_lists' was silently dropped by Cloudflare
    // (unrecognized keys are dropped, not rejected), leaving the generated token under-scoped.
    // 'firewall_services' is the zone-scoped permission for the access-rule fallback.
    private const TOKEN_TEMPLATE_SCOPES = [
        [ 'key' => 'zone', 'type' => 'read' ],
        [ 'key' => 'zone_waf', 'type' => 'edit' ],
        [ 'key' => 'account_rule_lists', 'type' => 'edit' ],
        [ 'key' => 'firewall_services', 'type' => 'edit' ],
    ];

    private const SETTINGS_VERSION = 4;
    private const LOCK_OPTION = 'sitefort_cf_sync_lock_option';
    private const LOCK_TTL = 120;
    private const DATA_LOCK_OPTION = 'sitefort_cf_data_lock_option';
    private const DATA_LOCK_TTL = 10;
    private const DATA_LOCK_WAIT_US = 20000;
    private const DATA_LOCK_MAX_ATTEMPTS = 20;
    private const LOCKOUT_RECONCILE_DELAY = 60;
    private const TEMP_BLOCKS_OPTION = 'sitefort_cf_temp_blocks';
    private const EVENT_HITS_OPTION = 'sitefort_cf_event_hits';
    private const MAX_TEMP_BLOCKS = 5000;
    private const MAX_EVENT_HITS = 2000;
    private const MAX_TEXT_ITEMS = 400;
    private const MAX_USER_AGENTS = 200;
    private const MAX_COUNTRIES = 250;
    private const MIN_MANAGED_EDGE_BLOCKS = 10;
    // Cloudflare caps custom-rule expressions at 4096 characters.
    private const MAX_EXPRESSION_LENGTH = 4096;
    private const EXPRESSION_POLICY_BUDGET = 2600;
    private const EXPRESSION_TRUSTED_BUDGET = 700;
    private const DEFAULT_RETRY_DELAY = 120;
    private const LIST_API_RETRY_DELAY = 21600;
    // Cloudflare bulk list writes normally finish in seconds; past this age an
    // operation stuck on an unrecognized status is treated as failed instead of
    // polling it forever.
    private const PENDING_OPERATION_TIMEOUT = 1800;
    private const DEFAULT_BLOCK_LIST_NAME = 'sitefort_block_ip';
    private const DEFAULT_ALLOW_LIST_NAME = 'sitefort_allow_ip';

    private Api $api;

    /** @var string|null Exact option value written by acquire_lock(), so release only deletes our own lock. */
    private ?string $sync_lock_value = null;

    public function __construct( ?Api $api = null ) {
        $this->api = $api ?? new Api();
    }

    /**
     * Return settings for admin UI without exposing secrets.
     *
     * @return array<string,mixed>
     */
    public function get_admin_settings(): array {
        $settings  = $this->get_settings();
        $api_token = SecretManager::get( 'cloudflare_api_token' );
        $has_token = is_string( $api_token ) && $api_token !== '';
        $auth_mode = $this->sanitize_auth_mode( (string) ( $settings['auth_mode'] ?? self::AUTH_MODE_API_TOKEN ) );

        $configured = ! empty( $settings['zone_id'] ) && $has_token;
        $permission_status = (string) ( $settings['sync_status']['permission_check']['status'] ?? 'unknown' );
        $connected = $configured && ! empty( $settings['account_id'] ) && $permission_status !== 'required_missing' && $permission_status !== 'verification_failed';

        $sync_status = $settings['sync_status'];
        $sync_status['configured'] = $configured;
        $sync_status['connected'] = $connected;

        return [
            'zone_id'           => $settings['zone_id'],
            'account_id'        => $settings['account_id'],
            'auth_mode'         => $auth_mode,
            'api_token_set'     => $has_token,
            'api_token_preview' => $this->mask_token_preview( is_string( $api_token ) ? $api_token : '' ),
            'enabled'           => (bool) $settings['enabled'],
            'connected'         => $connected,
            'configured'        => $configured,
            'policy'            => $settings['policy'],
            'lists'             => [
                'block_name' => $this->resolve_scoped_list_name( 'block', $settings ),
                'allow_name' => $this->resolve_scoped_list_name( 'allow', $settings ),
                'block_id'   => (string) $settings['lists']['block_id'],
                'allow_id'   => (string) $settings['lists']['allow_id'],
            ],
            'plan'              => $settings['plan'],
            'limits'            => $settings['limits'],
            'sync'              => $sync_status,
            'documentation_url' => self::DOCS_URL,
            'token_template_url' => $this->build_token_template_url( $settings ),
        ];
    }

    /**
     * Build the Cloudflare token-template deep link.
     *
     * Names the token per site domain so multi-site accounts do not collect
     * identical "SiteFort" entries, and scopes it to the specific account and
     * zone once known. First-time setup falls back to all accounts/zones for
     * the zone-level scopes because the zone is not resolved until a token
     * exists; the account-level Filter Lists scope is account-wide by nature
     * and cannot be narrowed further.
     *
     * @param array<string,mixed> $settings
     */
    private function build_token_template_url( array $settings ): string {
        $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
        $host = strtolower( preg_replace( '/^www\./', '', $host ) ?? $host );

        $name = 'SiteFort';
        if ( $host !== '' ) {
            $name .= ' - ' . $host;
        }

        $account_id = sanitize_text_field( (string) ( $settings['account_id'] ?? '' ) );
        $zone_id    = sanitize_text_field( (string) ( $settings['zone_id'] ?? '' ) );

        $query = 'permissionGroupKeys=' . rawurlencode( (string) wp_json_encode( self::TOKEN_TEMPLATE_SCOPES ) )
            . '&accountId=' . rawurlencode( $account_id !== '' ? $account_id : '*' )
            . '&zoneId=' . rawurlencode( $zone_id !== '' ? $zone_id : 'all' )
            . '&name=' . rawurlencode( $name );

        return 'https://dash.cloudflare.com/profile/api-tokens?' . $query;
    }

    /**
     * Return only the policy-facing state needed after policy saves.
     *
     * This intentionally excludes credential metadata and token previews.
     *
     * @return array<string,mixed>
     */
    public function get_policy_admin_state(): array {
        $settings = $this->get_settings();
        $api_token = SecretManager::get( 'cloudflare_api_token' );
        $has_credentials = is_string( $api_token ) && $api_token !== '';

        $configured = ! empty( $settings['zone_id'] ) && $has_credentials;
        $permission_status = (string) ( $settings['sync_status']['permission_check']['status'] ?? 'unknown' );
        $connected = $configured && ! empty( $settings['account_id'] ) && $permission_status !== 'required_missing' && $permission_status !== 'verification_failed';

        $sync_status = $settings['sync_status'];
        $sync_status['configured'] = $configured;
        $sync_status['connected'] = $connected;

        return [
            'enabled'    => (bool) $settings['enabled'],
            'configured' => $configured,
            'connected'  => $connected,
            'policy'     => $settings['policy'],
            'limits'     => $settings['limits'],
            'sync'       => $sync_status,
        ];
    }

    /**
     * Apply a remotely delivered Cloudflare secret and reconcile the local connection state.
     *
     * When a zone is already configured, this delegates back into save_connection()
     * so verification, permission auditing, and sync-state normalization remain
     * owned by the existing Cloudflare manager flow. If the site has not been
     * fully configured yet, the secret is stored locally and the connection stays
     * intentionally unverified until a zone is provided.
     *
     * @param array<string,mixed> $metadata
     * @return array<string,mixed>|\WP_Error
     */
    public function apply_transferred_secret( string $secret_name, string $secret_value, array $metadata = [] ) {
        $secret_name = sanitize_key( $secret_name );
        $settings = $this->get_settings();
        $zone_id = sanitize_text_field(
            trim(
                (string) ( $metadata['zone_id'] ?? ( $settings['zone_id'] ?? '' ) )
            )
        );
        if ( $secret_name !== 'api_token' ) {
            return new \WP_Error( 'cloudflare_secret_name_unsupported', 'Unsupported Cloudflare secret type.' );
        }

        $api_token = Api::normalize_api_token( $secret_value );
        if ( $api_token === '' ) {
            return new \WP_Error( 'cloudflare_token_required', 'Cloudflare API token is required.' );
        }
        if ( ! Api::is_valid_bearer_token( $api_token ) ) {
            return new \WP_Error(
                'cloudflare_token_invalid_format',
                'Cloudflare API token format is invalid. Paste the API Token value only (no "Authorization:", "Bearer", spaces, or unsupported symbols).'
            );
        }
        if ( Api::looks_like_cloudflare_token_identifier( $api_token ) ) {
            return new \WP_Error(
                'cloudflare_token_likely_id',
                'Cloudflare credential looks like a Global API Key or Token ID. This field accepts API Token value only.'
            );
        }

        if ( $zone_id !== '' ) {
            return $this->save_connection( $zone_id, $api_token );
        }

        if ( ! SecretManager::set( 'cloudflare_api_token', $api_token ) ) {
            return new \WP_Error(
                'cloudflare_token_store_failed',
                'Cloudflare token could not be stored securely. Check key permissions and retry.'
            );
        }

        $settings['auth_mode'] = self::AUTH_MODE_API_TOKEN;

        $settings['sync_status']['configured'] = false;
        $settings['sync_status']['connected'] = false;
        $settings['sync_status']['last_error'] = '';
        $settings['sync_status']['retry_count'] = 0;
        $settings['sync_status']['last_retry_at'] = 0;
        $this->save_settings( $settings );

        return [
            'message'  => 'Cloudflare API token saved. Add a Zone ID to verify the connection.',
            'verified' => false,
            'settings' => $this->get_admin_settings(),
        ];
    }

    /**
     * Save Cloudflare credentials and verify access.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function save_connection(
        string $zone_id,
        string $api_token = ''
    ) {
        $zone_id = sanitize_text_field( trim( $zone_id ) );
        $api_token = Api::normalize_api_token( $api_token );

        $settings = $this->get_settings();
        $previous_zone = (string) $settings['zone_id'];
        $settings['zone_id'] = $zone_id;
        $settings['auth_mode'] = self::AUTH_MODE_API_TOKEN;

        $has_new_token = $api_token !== '';

        if ( ! $has_new_token ) {
            $stored = SecretManager::get( 'cloudflare_api_token' );
            $api_token = is_string( $stored ) ? Api::normalize_api_token( $stored ) : '';
        }

        if ( $api_token === '' ) {
            $settings['sync_status']['configured'] = false;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = 'Cloudflare API token is missing.';
            $this->save_settings( $settings );
            return new \WP_Error( 'cloudflare_token_required', 'Cloudflare API token is required.' );
        }

        if ( ! Api::is_valid_bearer_token( $api_token ) ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = 'Cloudflare API token format is invalid.';
            $this->save_settings( $settings );
            return new \WP_Error(
                'cloudflare_token_invalid_format',
                'Cloudflare API token format is invalid. Paste the API Token value only (no "Authorization:", "Bearer", spaces, or unsupported symbols).'
            );
        }

        if ( Api::looks_like_cloudflare_token_identifier( $api_token ) ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = 'Credential looks like a Global API Key or Token ID. Use API Token value.';
            $this->save_settings( $settings );
            return new \WP_Error(
                'cloudflare_token_likely_id',
                'Cloudflare credential looks like a Global API Key or Token ID. This field accepts API Token value only.'
            );
        }

        // Auto-detect the zone from this site's own domain when no Zone ID was given.
        // The manual Zone ID field remains the fallback when discovery cannot match.
        if ( $zone_id === '' ) {
            $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
            $match = ( new Api( $api_token, '', '' ) )->find_zone_for_host( $host );

            if ( is_wp_error( $match ) ) {
                $settings['zone_id'] = '';
                $settings['sync_status']['configured'] = false;
                $settings['sync_status']['connected'] = false;
                $settings['sync_status']['last_error'] = $match->get_error_message();
                $this->save_settings( $settings );
                return $match;
            }

            if ( ! is_array( $match ) ) {
                $settings['zone_id'] = '';
                $settings['sync_status']['configured'] = false;
                $settings['sync_status']['connected'] = false;
                $settings['sync_status']['last_error'] = 'Could not match this site to a Cloudflare zone.';
                $this->save_settings( $settings );
                return new \WP_Error(
                    'cloudflare_zone_unresolved',
                    'SiteFort could not match this site to a Cloudflare zone on this token. Enter the Zone ID manually (Cloudflare Dashboard > your site > Overview).',
                    [ 'settings' => $this->get_admin_settings() ]
                );
            }

            $zone_id = $match['id'];
        }

        $settings['zone_id'] = $zone_id;

        $api = new Api(
            $api_token,
            $zone_id,
            $settings['account_id'] ?? ''
        );

        $verify = $api->verify_credentials();
        if ( is_wp_error( $verify ) ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = $verify->get_error_message();
            $this->save_settings( $settings );
            return $verify;
        }

        $account_id = $api->discover_account_id();
        if ( is_wp_error( $account_id ) ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = $account_id->get_error_message();
            $this->save_settings( $settings );
            return $account_id;
        }

        if ( $has_new_token && ! SecretManager::set( 'cloudflare_api_token', $api_token ) ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = 'Cloudflare token could not be stored securely.';
            $this->save_settings( $settings );
            return new \WP_Error(
                'cloudflare_token_store_failed',
                'Cloudflare token could not be stored securely. Check key permissions and retry.'
            );
        }

        if ( $previous_zone !== '' && $previous_zone !== $zone_id ) {
            $this->reset_managed_state( $settings );
        }

        $settings['account_id'] = $account_id;
        $permission_audit = $this->audit_permissions( $api, ! empty( $settings['policy']['fallback_to_access_rules'] ) );
        $settings['sync_status']['permission_check'] = $permission_audit;

        if ( $permission_audit['status'] === 'required_missing' || $permission_audit['status'] === 'verification_failed' ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = $permission_audit['message'];
            $this->save_settings( $settings );

            return new \WP_Error(
                'cloudflare_permission_audit_failed',
                $permission_audit['message'],
                [
                    'settings'         => $this->get_admin_settings(),
                    'permission_check' => $permission_audit,
                    'documentation_url'=> self::DOCS_URL,
                ]
            );
        }

        $this->refresh_plan_profile( $settings, $api );
        $settings['sync_status']['configured'] = true;
        $settings['sync_status']['connected'] = true;
        $settings['sync_status']['last_error'] = '';
        $settings['sync_status']['retry_count'] = 0;
        $settings['sync_status']['last_retry_at'] = 0;
        // Connecting implies intent to sync; the local-firewall gate can still veto.
        $settings['enabled'] = true;
        $this->apply_local_firewall_gate( $settings );
        $this->save_settings( $settings );
        $this->emit_settings_saved( $settings );
        $this->schedule_reconcile( 5 );

        $sync_note = ! empty( $settings['enabled'] ) ? ' Firewall rules now sync to the edge.' : '';

        return [
            'message'  => $permission_audit['status'] === 'optional_missing'
                ? 'Cloudflare connected.' . $sync_note . ' Optional fallback permission is missing.'
                : 'Cloudflare connected and verified.' . $sync_note,
            'settings' => $this->get_admin_settings(),
        ];
    }

    /**
     * API Token is the only supported auth mode. Any legacy stored value
     * (e.g. 'global_key') coerces to api_token so old settings never fatal.
     */
    private function sanitize_auth_mode( string $mode ): string {
        return self::AUTH_MODE_API_TOKEN;
    }

    /**
     * Disconnect Cloudflare: best-effort cleanup of SiteFort-managed edge resources,
     * then forget the stored token and reset connection state.
     *
     * Policy preferences (action, thresholds, manual block/allow entries) are kept so
     * reconnecting later does not require re-entering them.
     *
     * @return array<string,mixed>
     */
    public function disconnect(): array {
        $settings = $this->get_settings();
        $zone_id = (string) $settings['zone_id'];
        $account_id = (string) $settings['account_id'];
        $stored_token = SecretManager::get( 'cloudflare_api_token' );
        $api_token = is_string( $stored_token ) ? Api::normalize_api_token( $stored_token ) : '';

        // Failures here (e.g. the token was already revoked in Cloudflare) never block
        // disconnecting locally; this is a best-effort attempt to not leave stale rules behind.
        if ( $api_token !== '' && $zone_id !== '' ) {
            $api = new Api( $api_token, $zone_id, $account_id );
            $api->delete_managed_custom_rule();
            $api->sync_managed_access_rules( [], [], $this->build_access_rule_owner_tag( $settings ) );
            $this->delete_managed_lists( $api, $settings );
        }

        SecretManager::delete( 'cloudflare_api_token' );
        wp_clear_scheduled_hook( self::CRON_HOOK );

        $defaults = self::defaults();
        $settings['auth_mode']   = self::AUTH_MODE_API_TOKEN;
        $settings['zone_id']     = '';
        $settings['account_id']  = '';
        $settings['enabled']     = false;
        $settings['lists']       = $defaults['lists'];
        $settings['sync_status'] = $defaults['sync_status'];
        $settings['plan']        = $defaults['plan'];
        $settings['limits']      = $defaults['limits'];
        $this->save_settings( $settings );

        return [
            'message'  => 'Cloudflare disconnected. The saved token was removed and SiteFort-managed edge rules were cleared where possible.',
            'settings' => $this->get_admin_settings(),
        ];
    }

    /**
     * Best-effort removal of SiteFort edge enforcement on deactivation and
     * uninstall, so a deactivated plugin never leaves visitors blocked at the
     * edge. Deactivation keeps lists and credentials; uninstall passes
     * $delete_lists. Never throws.
     */
    public function clear_edge_enforcement( bool $delete_lists = false ): void {
        try {
            $settings = $this->get_settings();
            $zone_id = (string) $settings['zone_id'];
            $stored_token = SecretManager::get( 'cloudflare_api_token' );
            $api_token = is_string( $stored_token ) ? Api::normalize_api_token( $stored_token ) : '';

            if ( $api_token === '' || $zone_id === '' ) {
                return;
            }

            $api = new Api( $api_token, $zone_id, (string) $settings['account_id'] );
            $api->delete_managed_custom_rule();
            $api->sync_managed_access_rules( [], [], $this->build_access_rule_owner_tag( $settings ) );

            if ( $delete_lists ) {
                $this->delete_managed_lists( $api, $settings );
            }
        } catch ( \Throwable $e ) {
            // Never break deactivation or uninstall.
        }
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function delete_managed_lists( Api $api, array $settings ): void {
        foreach ( [ 'block_id', 'allow_id' ] as $key ) {
            $list_id = sanitize_text_field( (string) ( $settings['lists'][ $key ] ?? '' ) );
            if ( $list_id !== '' ) {
                $api->delete_list( $list_id );
            }
        }
    }

    /**
     * Verify current token.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function verify_connection() {
        $settings = $this->get_settings();
        $api = new Api();
        $verify = $api->verify_credentials();

        if ( is_wp_error( $verify ) ) {
            $settings['sync_status']['configured'] = $api->is_configured();
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = $verify->get_error_message();
            $this->save_settings( $settings );
            return $verify;
        }

        $account_id = $api->discover_account_id();
        if ( is_wp_error( $account_id ) ) {
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['connected'] = false;
            $settings['sync_status']['last_error'] = $account_id->get_error_message();
            $this->save_settings( $settings );
            return $account_id;
        }

        if ( (string) $settings['account_id'] !== $account_id ) {
            $settings['account_id'] = $account_id;
            $this->refresh_plan_profile( $settings, $api );
        }

        $permission_audit = $this->audit_permissions( $api, ! empty( $settings['policy']['fallback_to_access_rules'] ) );
        $settings['sync_status']['permission_check'] = $permission_audit;
        $settings['sync_status']['configured'] = true;
        $settings['sync_status']['connected'] = $permission_audit['status'] !== 'required_missing' && $permission_audit['status'] !== 'verification_failed' && ! empty( $settings['account_id'] );
        $settings['sync_status']['last_error'] = in_array( $permission_audit['status'], [ 'required_missing', 'verification_failed' ], true )
            ? $permission_audit['message']
            : '';
        $this->maybe_refresh_plan_profile( $settings, $api );
        $this->save_settings( $settings );

        if ( $permission_audit['status'] === 'required_missing' || $permission_audit['status'] === 'verification_failed' ) {
            return new \WP_Error(
                'cloudflare_permission_audit_failed',
                $permission_audit['message'],
                [
                    'settings'         => $this->get_admin_settings(),
                    'permission_check' => $permission_audit,
                    'documentation_url'=> self::DOCS_URL,
                ]
            );
        }

        return [
            'success'  => true,
            'message'  => $permission_audit['status'] === 'optional_missing'
                ? 'Cloudflare credentials verified. Optional fallback permission is missing.'
                : 'Cloudflare connection verified.',
            'settings' => $this->get_admin_settings(),
            'result'   => $verify,
        ];
    }

    /**
     * Save and sanitize policy.
     *
     * @param array<string,mixed> $policy_input
     * @return array<string,mixed>
     */
    public function save_policy( array $policy_input ): array {
        $settings = $this->get_settings();
        if ( array_key_exists( 'enabled', $policy_input ) ) {
            $settings['enabled'] = ! empty( $policy_input['enabled'] );
        }
        $this->apply_local_firewall_gate( $settings );
        $policy_settings = $policy_input;
        unset( $policy_settings['enabled'], $policy_settings['lists'] );
        $settings['policy'] = $this->sanitize_policy( $policy_settings, $settings['policy'] );
        $policy_limit_warnings = $this->apply_policy_limits( $settings['policy'], $settings['limits'] );
        $settings['sync_status']['limit_warnings'] = $this->refresh_policy_limit_warnings(
            is_array( $settings['sync_status']['limit_warnings'] ?? null ) ? $settings['sync_status']['limit_warnings'] : [],
            $policy_limit_warnings
        );

        $this->apply_scoped_list_names( $settings );

        $this->save_settings( $settings );
        $this->emit_settings_saved( $settings );
        $this->schedule_reconcile( 5 );

        return $this->get_policy_admin_state();
    }

    /**
     * Schedule an async reconcile run.
     */
    public function schedule_reconcile( int $delay_seconds = 10 ): void {
        $delay = max( 5, $delay_seconds );
        $timestamp = time() + $delay;

        if ( function_exists( '\\as_has_scheduled_action' ) && function_exists( '\\as_schedule_single_action' ) ) {
            if ( ! \as_has_scheduled_action( self::CRON_HOOK ) ) {
                \as_schedule_single_action( $timestamp, self::CRON_HOOK, [], 'sitefort_cloudflare' );
            }
            return;
        }

        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( ! $next ) {
            wp_schedule_single_event( $timestamp, self::CRON_HOOK );
            return;
        }

        if ( $next > ( $timestamp + 30 ) ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
            wp_schedule_single_event( $timestamp, self::CRON_HOOK );
        }
    }

    /**
     * Promote repeated runtime firewall events to temporary Cloudflare blocks.
     * Reconciliation remains batched so high traffic cannot push on every hit.
     *
     * @param array<string,mixed> $event
     */
    public function on_firewall_block( array $event ): void {
        $settings = $this->get_settings();
        $policy = $settings['policy'];

        if ( empty( $settings['enabled'] ) || empty( $policy['auto_block_firewall_events'] ) ) {
            return;
        }

        $ip = $this->sanitize_ip_or_cidr( (string) ( $event['ip'] ?? '' ) );
        if ( $ip === '' || ! $this->is_public_ip_or_cidr( $ip ) ) {
            return;
        }

        if ( ProtectedNetworkGuard::describe_block_rejection( $ip ) !== null ) {
            return;
        }

        if ( $this->record_event_hit( $ip, $policy ) ) {
            $this->schedule_reconcile( self::LOCKOUT_RECONCILE_DELAY );
        }
    }

    /**
     * Handle local firewall ban / allow mutations.
     *
     * Local firewall state is authoritative. When an IP is explicitly added to
     * the local banlist or allowlist, or when a local ban is removed/expired,
     * any transient Cloudflare-only runtime state for the same IP must be
     * cleared so the next reconcile reflects the local decision exactly.
     *
     * @param array<string,mixed> $payload
     */
    public function on_firewall_lockout_changed( array $payload ): void {
        $event = sanitize_key( (string) ( $payload['event'] ?? '' ) );
        if ( ! in_array( $event, [ 'added', 'removed', 'purged', 'expired' ], true ) ) {
            return;
        }

        $type = (string) ( $payload['type'] ?? 'ban' );
        if ( ! in_array( $type, [ 'ban', 'allow' ], true ) ) {
            return;
        }

        $ip = $this->sanitize_ip_or_cidr( (string) ( $payload['ip'] ?? '' ) );
        $should_clear_runtime_state =
            $event === 'added'
            || ( in_array( $event, [ 'removed', 'expired', 'purged' ], true ) && $type === 'ban' );

        if ( $should_clear_runtime_state && $ip !== '' ) {
            $this->clear_runtime_block_state( $ip );
        }

        $this->schedule_reconcile( self::LOCKOUT_RECONCILE_DELAY );
    }

    /**
     * Reconcile desired Cloudflare state.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function reconcile( bool $force = false ) {
        if ( ! $this->acquire_lock( $force ) ) {
            $this->schedule_reconcile( 20 );
            return [
                'success' => false,
                'busy'    => true,
                'message' => 'Cloudflare sync already running.',
                'retry_scheduled' => true,
                'settings'=> $this->get_admin_settings(),
            ];
        }

        try {
            $settings = $this->get_settings();

            $this->prune_temp_blocks();
            $desired = $this->build_desired_entry_sets( $settings );
            $limited = $this->apply_plan_limits( $settings, $desired['block'], $desired['allow'] );
            $block_entries = $limited['block'];
            $allow_entries = $limited['allow'];
            $limit_warnings = array_merge( $limited['warnings'], $desired['warnings'] );
            $settings['sync_status']['limit_warnings'] = array_values( array_unique( $limit_warnings ) );

            $settings['sync_status']['block_entries'] = count( $block_entries );
            $settings['sync_status']['allow_entries'] = count( $allow_entries );

            $zone_id = (string) $settings['zone_id'];
            $account_id = (string) $settings['account_id'];
            $stored_token = SecretManager::get( 'cloudflare_api_token' );
            $api_token = is_string( $stored_token ) ? Api::normalize_api_token( $stored_token ) : '';

            $api = new Api( $api_token, $zone_id, $account_id );

            $settings['sync_status']['configured'] = $api->is_configured();
            if ( ! $settings['sync_status']['configured'] ) {
                $settings['sync_status']['connected'] = false;
                $settings['sync_status']['sync_mode'] = 'none';
                $settings['sync_status']['last_error'] = 'Cloudflare credentials are incomplete.';
                $this->save_settings( $settings );

                return new \WP_Error( 'cloudflare_not_configured', 'Cloudflare credentials are incomplete.' );
            }

            if ( ! $api->has_account_id() ) {
                $resolved_account = $api->discover_account_id();
                if ( is_wp_error( $resolved_account ) ) {
                    $this->mark_sync_failure( $settings, $resolved_account );
                    return $resolved_account;
                }
                $settings['account_id'] = $resolved_account;
                $this->refresh_plan_profile( $settings, $api );
            }
            $this->maybe_refresh_plan_profile( $settings, $api );

            $pending = $this->process_pending_operations( $settings, $api );
            if ( is_wp_error( $pending ) ) {
                $this->mark_sync_failure( $settings, $pending );
                return $pending;
            }
            if ( ! empty( $pending['pending'] ) ) {
                $settings['sync_status']['pending'] = true;
                $settings['sync_status']['sync_mode'] = 'pending';
                $settings['sync_status']['last_error'] = '';
                $settings['sync_status']['last_warning'] = '';
                $settings['sync_status']['warning_targets'] = [];
                $settings['sync_status']['connected'] = true;
                $settings['sync_status']['configured'] = true;
                $this->save_settings( $settings );
                $this->schedule_reconcile( 60 );

                return [
                    'success' => true,
                    'pending' => true,
                    'message' => 'Cloudflare is still processing the previous update.',
                    'settings' => $this->get_admin_settings(),
                ];
            }

            $sync_mode = 'none';
            $ip_clause = '';
            $enforcement = [
                'warning_message' => '',
                'warning_targets' => [],
            ];

            if ( ! empty( $settings['enabled'] ) ) {
                $enforcement = $this->sync_ip_enforcement( $settings, $api, $block_entries, $allow_entries, $force );
                if ( is_wp_error( $enforcement ) ) {
                    $this->mark_sync_failure( $settings, $enforcement );
                    return $enforcement;
                }

                $sync_mode = (string) ( $enforcement['mode'] ?? 'none' );
                $ip_clause = (string) ( $enforcement['ip_clause'] ?? '' );

                if ( ! empty( $enforcement['pending'] ) ) {
                    $settings['sync_status']['pending'] = true;
                    $settings['sync_status']['sync_mode'] = 'pending';
                    $settings['sync_status']['last_error'] = '';
                    $settings['sync_status']['last_warning'] = '';
                    $settings['sync_status']['warning_targets'] = [];
                    $settings['sync_status']['connected'] = true;
                    $settings['sync_status']['configured'] = true;
                    $this->save_settings( $settings );
                    $this->schedule_reconcile( 60 );

                    return [
                        'success' => true,
                        'pending' => true,
                        'message' => 'Cloudflare update queued.',
                        'settings' => $this->get_admin_settings(),
                    ];
                }
            } else {
                $clear_rule = $api->delete_managed_custom_rule();
                if ( is_wp_error( $clear_rule ) ) {
                    $this->mark_sync_failure( $settings, $clear_rule );
                    return $clear_rule;
                }

                if ( (string) $settings['sync_status']['sync_mode'] === 'access_rules_fallback' ) {
                    $clear_access = $api->sync_managed_access_rules( [], [], $this->build_access_rule_owner_tag( $settings ) );
                    if ( is_wp_error( $clear_access ) ) {
                        $this->mark_sync_failure( $settings, $clear_access );
                        return $clear_access;
                    }
                }

                $settings['sync_status']['managed_rule_id'] = '';
                $settings['sync_status']['managed_ruleset_id'] = '';
            }

            $policy_result = $this->build_policy_expression( $settings );
            $policy_clause = (string) ( $policy_result['expression'] ?? '' );
            $trusted_user_agent_clause = (string) ( $policy_result['trusted_expression'] ?? '' );
            $limit_warnings = array_merge(
                $limit_warnings,
                is_array( $policy_result['warnings'] ?? null ) ? $policy_result['warnings'] : []
            );
            if ( $sync_mode === 'access_rules_fallback' && $trusted_user_agent_clause !== '' && ! empty( $block_entries ) ) {
                $limit_warnings[] = 'Trusted user-agent bypass does not override Cloudflare IP access rules in fallback mode.';
            }
            if ( $sync_mode === 'access_rules_fallback' && ! empty( $settings['policy']['bypass_verified_bots'] ) && ! empty( $block_entries ) ) {
                $limit_warnings[] = 'Verified-bot bypass does not override Cloudflare IP access rules in fallback mode.';
            }
            $limit_warnings = array_merge(
                $limit_warnings,
                is_array( $enforcement['warnings'] ?? null ) ? $enforcement['warnings'] : []
            );

            $expression = $this->combine_expression_clauses( $ip_clause, $policy_clause );
            if ( $expression !== '' && $trusted_user_agent_clause !== '' ) {
                $expression = '(' . $expression . ') and not (' . $trusted_user_agent_clause . ')';
            }
            if ( $expression !== '' && $sync_mode === 'lists' && ! empty( $allow_entries ) ) {
                $expression = '(' . $expression . ' and not ip.src in $' . $settings['lists']['allow_name'] . ')';
            }
            if ( $expression !== '' && $sync_mode === 'access_rules_fallback' && ! empty( $allow_entries ) ) {
                // Access-rule whitelists do not bypass WAF custom rules.
                $inline_trimmed = 0;
                $inline_set = $this->build_inline_ip_set(
                    $allow_entries,
                    max( 0, self::MAX_EXPRESSION_LENGTH - strlen( $expression ) - 160 ),
                    $inline_trimmed
                );
                if ( $inline_set !== '' ) {
                    $expression = '(' . $expression . ' and not ip.src in ' . $inline_set . ')';
                }
                if ( $inline_trimmed > 0 ) {
                    $limit_warnings[] = sprintf(
                        '%d allow entries could not be excluded from the edge rule in fallback mode (expression size limit).',
                        $inline_trimmed
                    );
                }
            }
            if ( $expression !== '' && ! empty( $settings['policy']['bypass_verified_bots'] ) ) {
                $expression = '(' . $expression . ' and not cf.client.bot)';
            }
            $settings['sync_status']['limit_warnings'] = array_values( array_unique( $limit_warnings ) );

            if ( ! empty( $settings['enabled'] ) && $expression !== '' ) {
                $rule = $api->ensure_managed_custom_rule( $expression, true );

                if ( is_wp_error( $rule ) ) {
                    $this->mark_sync_failure( $settings, $rule );
                    return $rule;
                }

                $settings['sync_status']['managed_rule_id'] = (string) ( $rule['rule_id'] ?? '' );
                $settings['sync_status']['managed_ruleset_id'] = (string) ( $rule['ruleset_id'] ?? '' );
            } else {
                $delete = $api->delete_managed_custom_rule();
                if ( is_wp_error( $delete ) ) {
                    $this->mark_sync_failure( $settings, $delete );
                    return $delete;
                }

                $settings['sync_status']['managed_rule_id'] = '';
                $settings['sync_status']['managed_ruleset_id'] = '';
            }

            $settings['sync_status']['connected'] = true;
            $settings['sync_status']['configured'] = true;
            $settings['sync_status']['pending'] = false;
            $settings['sync_status']['sync_mode'] = $sync_mode;
            $settings['sync_status']['last_error'] = '';
            $settings['sync_status']['last_warning'] = sanitize_text_field( (string) ( $enforcement['warning_message'] ?? '' ) );
            $settings['sync_status']['warning_targets'] = $this->sanitize_text_list(
                is_array( $enforcement['warning_targets'] ?? null ) ? $enforcement['warning_targets'] : [],
                20,
                120
            );
            $settings['sync_status']['retry_count'] = 0;
            $settings['sync_status']['last_retry_at'] = 0;
            $settings['sync_status']['last_sync_at'] = time();

            $this->save_settings( $settings );
            $this->schedule_temp_block_expiry_reconcile();

            return [
                'success'  => true,
                'warning'  => $settings['sync_status']['last_warning'] !== '',
                'message'  => $settings['sync_status']['last_warning'] !== ''
                    ? $settings['sync_status']['last_warning']
                    : 'Rules pushed to Cloudflare.',
                'settings' => $this->get_admin_settings(),
            ];
        } finally {
            $this->release_lock();
        }
    }

    /**
     * Build desired allow/block sets from policy + local lockouts + temporary edge blocks.
     *
     * @param array<string,mixed> $settings
     * @return array{block:string[],allow:string[],warnings:string[]}
     */
    private function build_desired_entry_sets( array $settings ): array {
        $policy = $settings['policy'];

        $block    = [];
        $allow    = [];
        $warnings = [];

        $block = array_merge( $block, $this->sanitize_ip_entries( $policy['manual_block_entries'] ?? [] ) );
        $block = array_merge( $block, $this->sanitize_ip_entries( $policy['networks'] ?? [] ) );
        $allow = array_merge( $allow, $this->sanitize_ip_entries( $policy['manual_allow_entries'] ?? [] ) );

        if ( ! empty( $policy['sync_local_banlist'] ) ) {
            $fw_raw      = SettingsStore::get( SettingsStore::COL_FIREWALL );
            $raw_lines   = preg_split( '/[\r\n,]+/', (string) ( $fw_raw['banned_ips'] ?? '' ), -1, PREG_SPLIT_NO_EMPTY );
            $wc_count    = count( array_filter( (array) $raw_lines, static fn( $l ) => str_contains( trim( (string) $l ), '*' ) ) );
            if ( $wc_count > 0 ) {
                $warnings[] = sprintf(
                    '%d wildcard IP pattern(s) in the local ban list are not supported by Cloudflare and were skipped during sync.',
                    $wc_count
                );
            }
            $block = array_merge( $block, $this->get_local_lockout_entries( 'banned_ips' ) );
        }

        if ( ! empty( $policy['sync_local_allowlist'] ) ) {
            $allow = array_merge( $allow, $this->get_local_lockout_entries( 'allowlist' ) );
        }

        if ( ! empty( $policy['auto_block_firewall_events'] ) ) {
            $temp_blocks = $this->get_temp_blocks();
            $block = array_merge( $block, array_keys( $temp_blocks ) );
        }

        $allow = array_values( array_unique( $allow ) );
        $block = array_values( array_unique( $block ) );

        // Allowlist takes precedence.
        $allow_map = array_fill_keys( $allow, true );
        $block = array_values(
            array_filter(
                $block,
                static fn( string $ip ) => ! isset( $allow_map[ $ip ] )
            )
        );

        $filtered_block_entries = ProtectedNetworkGuard::filter_block_entries( $block );
        $block = $filtered_block_entries['allowed'];
        if ( ! empty( $filtered_block_entries['skipped'] ) ) {
            $skipped_entries = array_map(
                static fn( array $item ): string => (string) $item['entry'],
                $filtered_block_entries['skipped']
            );
            $warnings[] = sprintf(
                '%d Cloudflare proxy IP or range entr%s were skipped during edge sync: %s',
                count( $skipped_entries ),
                count( $skipped_entries ) === 1 ? 'y' : 'ies',
                implode( ', ', array_slice( $skipped_entries, 0, 5 ) )
            );
        }

        $max_entries = max( self::MIN_MANAGED_EDGE_BLOCKS, min( 50000, (int) ( $policy['max_block_entries'] ?? 5000 ) ) );
        if ( count( $block ) > $max_entries ) {
            $block = array_slice( $block, 0, $max_entries );
        }

        if ( count( $allow ) > $max_entries ) {
            $allow = array_slice( $allow, 0, $max_entries );
        }

        return [
            'block'    => $block,
            'allow'    => $allow,
            'warnings' => $warnings,
        ];
    }

    /**
     * Sync IP controls via lists when possible, fallback to access rules.
     *
     * @param array<string,mixed> $settings
     * @param string[]            $block_entries
     * @param string[]            $allow_entries
     * @return array<string,mixed>|\WP_Error
     */
    private function sync_ip_enforcement( array &$settings, Api $api, array $block_entries, array $allow_entries, bool $force = false ) {
        $fallback = ! empty( $settings['policy']['fallback_to_access_rules'] );
        $this->apply_scoped_list_names( $settings );

        if ( $fallback && $this->should_prefer_access_rules_fallback( $settings ) ) {
            $this->clear_list_api_failure( $settings );
            return $this->sync_access_rules_fallback( $settings, $api, $block_entries, $allow_entries );
        }

        if ( $fallback && $this->should_skip_list_provisioning( $settings, $force ) ) {
            return $this->sync_access_rules_fallback( $settings, $api, $block_entries, $allow_entries );
        }

        $block_name = $this->sanitize_list_name( (string) $settings['lists']['block_name'], self::DEFAULT_BLOCK_LIST_NAME );
        $allow_name = $this->sanitize_list_name( (string) $settings['lists']['allow_name'], self::DEFAULT_ALLOW_LIST_NAME );

        $settings['lists']['block_name'] = $block_name;
        $settings['lists']['allow_name'] = $allow_name;

        $block_list = $api->ensure_list( $block_name, 'ip', 'SiteFort block list' );
        if ( is_wp_error( $block_list ) ) {
            if ( $fallback ) {
                $this->remember_list_api_failure( $settings, $block_list );
                return $this->sync_access_rules_fallback( $settings, $api, $block_entries, $allow_entries );
            }
            return $block_list;
        }

        $allow_list = $api->ensure_list( $allow_name, 'ip', 'SiteFort allow list' );
        if ( is_wp_error( $allow_list ) ) {
            if ( $fallback ) {
                $this->remember_list_api_failure( $settings, $allow_list );
                return $this->sync_access_rules_fallback( $settings, $api, $block_entries, $allow_entries );
            }
            return $allow_list;
        }

        $this->clear_list_api_failure( $settings );

        $settings['lists']['block_id'] = sanitize_text_field( (string) ( $block_list['id'] ?? '' ) );
        $settings['lists']['allow_id'] = sanitize_text_field( (string) ( $allow_list['id'] ?? '' ) );
        if ( $settings['lists']['block_id'] === '' || $settings['lists']['allow_id'] === '' ) {
            return new \WP_Error( 'cloudflare_list_id_missing', 'Cloudflare list provisioning returned an empty list ID.' );
        }

        $block_replace = $api->replace_ip_list_items( $settings['lists']['block_id'], $block_entries );
        if ( is_wp_error( $block_replace ) ) {
            if ( $fallback ) {
                return $this->sync_access_rules_fallback( $settings, $api, $block_entries, $allow_entries );
            }
            return $block_replace;
        }

        $allow_replace = $api->replace_ip_list_items( $settings['lists']['allow_id'], $allow_entries );
        if ( is_wp_error( $allow_replace ) ) {
            if ( $fallback ) {
                return $this->sync_access_rules_fallback( $settings, $api, $block_entries, $allow_entries );
            }
            return $allow_replace;
        }

        $skip_warnings = [];
        foreach ( [ 'block' => $block_replace, 'allow' => $allow_replace ] as $kind => $replace_result ) {
            $skipped = is_array( $replace_result['skipped'] ?? null ) ? $replace_result['skipped'] : [];
            if ( ! empty( $skipped ) ) {
                $skip_warnings[] = sprintf(
                    '%d %s entr%s not supported by Cloudflare Lists were skipped: %s',
                    count( $skipped ),
                    $kind,
                    count( $skipped ) === 1 ? 'y' : 'ies',
                    implode( ', ', array_slice( array_map( 'sanitize_text_field', $skipped ), 0, 5 ) )
                );
            }
        }

        $pending_operations = [];
        if ( ! empty( $block_replace['operation_id'] ) ) {
            $pending_operations[] = sanitize_text_field( (string) $block_replace['operation_id'] );
        }
        if ( ! empty( $allow_replace['operation_id'] ) ) {
            $pending_operations[] = sanitize_text_field( (string) $allow_replace['operation_id'] );
        }

        $settings['lists']['pending_operations'] = array_values( array_unique( $pending_operations ) );
        $settings['lists']['pending_since'] = empty( $pending_operations ) ? 0 : time();

        if ( ! empty( $pending_operations ) ) {
            return [
                'mode'    => 'pending',
                'pending' => true,
                'ip_clause' => '',
                'warnings' => $skip_warnings,
            ];
        }

        // Clear managed fallback rules only after fallback mode was actually used.
        if ( (string) $settings['sync_status']['sync_mode'] === 'access_rules_fallback' ) {
            $clear_fallback = $api->sync_managed_access_rules( [], [], $this->build_access_rule_owner_tag( $settings ) );
            if ( is_wp_error( $clear_fallback ) ) {
                return $clear_fallback;
            }
        }

        $ip_clause = '';
        if ( ! empty( $block_entries ) ) {
            $ip_clause = 'ip.src in $' . $block_name;
        }

        return [
            'mode'      => 'lists',
            'pending'   => false,
            'ip_clause' => $ip_clause,
            'warnings'  => $skip_warnings,
        ];
    }

    /**
     * @param array<string,mixed> $settings
     * @param string[]            $block_entries
     * @param string[]            $allow_entries
     * @return array<string,mixed>|\WP_Error
     */
    private function sync_access_rules_fallback( array &$settings, Api $api, array $block_entries, array $allow_entries ) {
        // Nothing to push and nothing managed there: skip the round-trip so
        // tokens without the fallback scope can still run policy-only syncs.
        if ( empty( $block_entries ) && empty( $allow_entries )
            && (string) $settings['sync_status']['sync_mode'] !== 'access_rules_fallback'
        ) {
            $this->clear_pending_operations( $settings );

            return [
                'mode'            => 'none',
                'pending'         => false,
                'ip_clause'       => '',
                'warnings'        => [],
                'warning_message' => '',
                'warning_targets' => [],
            ];
        }

        $fallback = $api->sync_managed_access_rules( $block_entries, $allow_entries, $this->build_access_rule_owner_tag( $settings ) );
        if ( is_wp_error( $fallback ) ) {
            return $fallback;
        }

        $this->clear_pending_operations( $settings );

        $warnings = [];
        $skipped_targets = is_array( $fallback['skipped_targets'] ?? null ) ? $fallback['skipped_targets'] : [];
        if ( ! empty( $skipped_targets ) ) {
            $warnings[] = sprintf(
                '%d entr%s not supported by Cloudflare IP Access Rules (ranges must be IPv4 /16 or /24, IPv6 /32, /48 or /64) were skipped in fallback mode: %s',
                count( $skipped_targets ),
                count( $skipped_targets ) === 1 ? 'y' : 'ies',
                implode( ', ', array_slice( array_map( 'sanitize_text_field', $skipped_targets ), 0, 5 ) )
            );
        }

        return [
            // A run with no entries is a cleanup; nothing stays managed here.
            'mode'            => ( empty( $block_entries ) && empty( $allow_entries ) ) ? 'none' : 'access_rules_fallback',
            'pending'         => false,
            'ip_clause'       => '',
            'warnings'        => $warnings,
            'warning_message' => sanitize_text_field( (string) ( $fallback['warning_message'] ?? '' ) ),
            'warning_targets' => $this->sanitize_text_list(
                is_array( $fallback['warning_targets'] ?? null ) ? $fallback['warning_targets'] : [],
                20,
                120
            ),
        ];
    }

    /**
     * Avoid repeated automatic list-provisioning probes when Cloudflare has
     * already told us the Lists API is unavailable for this account/plan state.
     *
     * @param array<string,mixed> $settings
     */
    private function should_skip_list_provisioning( array $settings, bool $force ): bool {
        if ( $force ) {
            return false;
        }

        $retry_after = (int) ( $settings['sync_status']['list_api_retry_after'] ?? 0 );
        return $retry_after > time();
    }

    /**
     * Cloudflare free plans should stay on access-rule fallback instead of
     * probing list provisioning that the product does not support there.
     *
     * @param array<string,mixed> $settings
     */
    private function should_prefer_access_rules_fallback( array $settings ): bool {
        $plan_key = sanitize_key( (string) ( $settings['plan']['key'] ?? 'unknown' ) );
        return $plan_key === 'free';
    }

    /**
     * Persist a short backoff after Cloudflare list provisioning fails so
     * automatic reconciles can stay on fallback mode without repeated 4xx noise.
     *
     * @param array<string,mixed> $settings
     */
    private function remember_list_api_failure( array &$settings, \WP_Error $error ): void {
        $settings['sync_status']['list_api_retry_after'] = time() + self::LIST_API_RETRY_DELAY;
        $settings['sync_status']['list_api_retry_reason'] = sanitize_text_field( $error->get_error_message() );
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function clear_list_api_failure( array &$settings ): void {
        $settings['sync_status']['list_api_retry_after'] = 0;
        $settings['sync_status']['list_api_retry_reason'] = '';
    }

    /**
     * Poll pending list operations.
     *
     * @param array<string,mixed> $settings
     * @return array<string,mixed>|\WP_Error
     */
    private function process_pending_operations( array &$settings, Api $api ) {
        $operations = $settings['lists']['pending_operations'];
        if ( ! is_array( $operations ) || empty( $operations ) ) {
            return [ 'pending' => false ];
        }

        $still_pending = [];
        foreach ( $operations as $operation_id ) {
            $operation_id = sanitize_text_field( (string) $operation_id );
            if ( $operation_id === '' ) {
                continue;
            }

            $op = $api->get_list_bulk_operation( $operation_id );
            if ( is_wp_error( $op ) ) {
                return $op;
            }

            $status_raw = strtolower(
                sanitize_text_field(
                    (string) (
                        $op['result']['status']
                        ?? $op['result']['state']
                        ?? $op['result']['phase']
                        ?? ''
                    )
                )
            );

            if ( in_array( $status_raw, [ 'pending', 'running', 'processing', 'in_progress' ], true ) ) {
                $still_pending[] = $operation_id;
                continue;
            }

            if ( in_array( $status_raw, [ 'failed', 'error', 'cancelled', 'canceled', 'aborted', 'timeout' ], true ) ) {
                $message = sanitize_text_field(
                    (string) (
                        $op['result']['error']
                        ?? $op['result']['message']
                        ?? 'Cloudflare list operation failed.'
                    )
                );

                // A failed bulk operation stays failed forever on Cloudflare's
                // side; keeping it queued would wedge every future reconcile.
                $this->clear_pending_operations( $settings );

                return new \WP_Error(
                    'cloudflare_list_operation_failed',
                    sprintf( 'Cloudflare list operation %s failed: %s', $operation_id, $message )
                );
            }

            if ( ! in_array( $status_raw, [ 'completed', 'done', 'success', 'succeeded', 'finished' ], true ) ) {
                $still_pending[] = $operation_id;
            }
        }

        if ( empty( $still_pending ) ) {
            $this->clear_pending_operations( $settings );

            return [ 'pending' => false ];
        }

        $pending_since = (int) ( $settings['lists']['pending_since'] ?? 0 );
        if ( $pending_since > 0 && ( time() - $pending_since ) > self::PENDING_OPERATION_TIMEOUT ) {
            $this->clear_pending_operations( $settings );

            return new \WP_Error(
                'cloudflare_list_operation_timeout',
                'Cloudflare list operation did not complete in time; the next sync will rebuild the lists.'
            );
        }

        $settings['lists']['pending_operations'] = $still_pending;

        return [ 'pending' => true ];
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function clear_pending_operations( array &$settings ): void {
        $settings['lists']['pending_operations'] = [];
        $settings['lists']['pending_since'] = 0;
    }

    /**
     * Build Cloudflare expression clauses for non-IP policy.
     *
     * Country and user-agent clauses mirror local firewall settings when those
     * features are enabled locally. When local features are disabled, CF-specific
     * values from the Cloudflare policy are used.
     *
     * @param array<string,mixed> $settings
     * @return array{expression:string,trusted_expression:string,warnings:string[]}
     */
    private function build_policy_expression( array $settings ): array {
        $policy = is_array( $settings['policy'] ?? null ) ? $settings['policy'] : [];
        $limits = is_array( $settings['limits'] ?? null ) ? $settings['limits'] : [];
        $firewall = SettingsStore::get( SettingsStore::COL_FIREWALL );

        $clauses = [];
        $warnings = [];

        $country_mode = 'off';
        $countries    = [];
        if ( ! empty( $firewall['geoip_enabled'] ) ) {
            $geo_mode     = sanitize_key( (string) ( $firewall['geoip_mode'] ?? 'block_list' ) );
            $country_mode = $geo_mode === 'allow_list' ? 'allow' : 'block';
            $countries    = $this->sanitize_country_codes( $firewall['geoip_countries'] ?? '' );
        } elseif ( ! empty( $policy['country_mode'] ) && $policy['country_mode'] !== 'off' ) {
            $country_mode = $policy['country_mode'];
            $countries    = $this->sanitize_country_codes( $policy['countries'] ?? [] );
        }
        $country_cap = max( 1, (int) ( $limits['max_countries'] ?? self::MAX_COUNTRIES ) );
        if ( count( $countries ) > $country_cap ) {
            $countries = array_slice( $countries, 0, $country_cap );
            $warnings[] = sprintf( 'Country list trimmed to %d entries (plan cap).', $country_cap );
        }

        $budget_remaining = self::EXPRESSION_POLICY_BUDGET;

        if ( in_array( $country_mode, [ 'block', 'allow' ], true ) && ! empty( $countries ) ) {
            // 5 chars per country ("US" + separator).
            $country_overhead = 30;
            $max_country_entries = max( 1, intdiv( $budget_remaining - $country_overhead, 5 ) );
            if ( count( $countries ) > $max_country_entries ) {
                $countries = array_slice( $countries, 0, $max_country_entries );
                $warnings[] = sprintf( 'Country list trimmed to %d entries (edge rule size limit).', $max_country_entries );
            }

            $country_set = '{' . implode( ' ', array_map( static fn( string $code ): string => '"' . $code . '"', $countries ) ) . '}';
            // ip.src.country is the current field name; ip.geoip.country is its deprecated alias.
            $clauses[] = ( $country_mode === 'allow' ? 'not ' : '' ) . 'ip.src.country in ' . $country_set;
            $budget_remaining -= strlen( end( $clauses ) );
        }

        $local_ua_raw = (string) ( $firewall['banned_user_agents'] ?? '' );
        $user_agents  = $local_ua_raw !== ''
            ? $this->sanitize_user_agents( $local_ua_raw )
            : $this->sanitize_user_agents( $policy['user_agents'] ?? [] );
        $trusted_user_agents = $this->sanitize_user_agents( (string) ( $firewall['trusted_user_agents'] ?? '' ) );
        $ua_cap = max( 1, (int) ( $limits['max_user_agents'] ?? self::MAX_USER_AGENTS ) );
        if ( count( $user_agents ) > $ua_cap ) {
            $user_agents = array_slice( $user_agents, 0, $ua_cap );
            $warnings[] = sprintf( 'User-agent list trimmed to %d entries (plan cap).', $ua_cap );
        }
        if ( count( $trusted_user_agents ) > $ua_cap ) {
            $trusted_user_agents = array_slice( $trusted_user_agents, 0, $ua_cap );
            $warnings[] = sprintf( 'Trusted user-agent list trimmed to %d entries (plan cap).', $ua_cap );
        }

        $ua_trimmed = 0;
        $ua_group = $this->build_bounded_ua_group( $user_agents, max( 0, $budget_remaining ), $ua_trimmed );
        if ( $ua_group !== '' ) {
            $clauses[] = $ua_group;
        }
        if ( $ua_trimmed > 0 ) {
            $warnings[] = sprintf( '%d user-agent entries were left out of the edge rule (expression size limit).', $ua_trimmed );
        }

        $trusted_trimmed = 0;
        $trusted_expression = $this->build_bounded_ua_group( $trusted_user_agents, self::EXPRESSION_TRUSTED_BUDGET, $trusted_trimmed );
        if ( $trusted_trimmed > 0 ) {
            $warnings[] = sprintf( '%d trusted user-agent entries were left out of the edge bypass (expression size limit).', $trusted_trimmed );
        }

        if ( empty( $clauses ) ) {
            return [
                'expression' => '',
                'trusted_expression' => $trusted_expression,
                'warnings'   => $warnings,
            ];
        }

        $expression = count( $clauses ) === 1
            ? $clauses[0]
            : '(' . implode( ' or ', $clauses ) . ')';

        return [
            'expression' => $expression,
            'trusted_expression' => $trusted_expression,
            'warnings'   => $warnings,
        ];
    }

    /**
     * OR-group of user-agent contains clauses capped by byte budget; $trimmed
     * reports how many agents did not fit.
     *
     * @param string[] $agents
     */
    private function build_bounded_ua_group( array $agents, int $budget, int &$trimmed ): string {
        $clauses = [];
        $length = 2;
        $trimmed = 0;

        foreach ( $agents as $agent ) {
            $clause = 'lower(http.user_agent) contains "' . $this->escape_expression_string( strtolower( $agent ) ) . '"';
            $cost = strlen( $clause ) + 4;
            if ( $length + $cost > $budget ) {
                $trimmed++;
                continue;
            }
            $clauses[] = $clause;
            $length += $cost;
        }

        if ( $clauses === [] ) {
            $trimmed = count( $agents );
            return '';
        }

        return count( $clauses ) === 1
            ? $clauses[0]
            : '(' . implode( ' or ', $clauses ) . ')';
    }

    private function combine_expression_clauses( string $ip_clause, string $policy_clause ): string {
        $parts = [];
        if ( $ip_clause !== '' ) {
            $parts[] = $ip_clause;
        }
        if ( $policy_clause !== '' ) {
            $parts[] = $policy_clause;
        }

        if ( empty( $parts ) ) {
            return '';
        }

        if ( count( $parts ) === 1 ) {
            return $parts[0];
        }

        return '(' . implode( ' or ', $parts ) . ')';
    }

    /**
     * Store retry metadata and schedule backoff sync. Permission-shaped
     * failures fold back into permission_check so the UI names the scope.
     */
    private function mark_sync_failure( array &$settings, \WP_Error $error ): void {
        $retry_count = (int) ( $settings['sync_status']['retry_count'] ?? 0 ) + 1;
        $settings['sync_status']['retry_count'] = $retry_count;
        $settings['sync_status']['last_retry_at'] = time();
        $settings['sync_status']['last_error'] = sanitize_text_field( $error->get_error_message() );
        $settings['sync_status']['last_warning'] = '';
        $settings['sync_status']['warning_targets'] = [];
        $settings['sync_status']['pending'] = false;
        $settings['sync_status']['connected'] = ! empty( $settings['account_id'] );
        $settings['sync_status']['configured'] = true;
        $this->apply_permission_failure_state( $settings, $error );

        $delay = $this->calculate_retry_delay( $retry_count );
        $this->save_settings( $settings );
        $this->schedule_reconcile( $delay );
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function apply_permission_failure_state( array &$settings, \WP_Error $error ): void {
        if ( ! $this->error_looks_like_permission_issue( $error ) ) {
            return;
        }

        $item = $this->permission_item_for_error( $error );
        if ( $item === null ) {
            return;
        }

        $check = self::default_permission_check();
        $check['status'] = 'required_missing';
        $check['checked_at'] = time();
        $check['missing_required'] = [ $item ];
        $check['message'] = 'Cloudflare sync failed because the token is missing a permission: ' . $item['label'] . '.';

        $settings['sync_status']['permission_check'] = $check;
        $settings['sync_status']['connected'] = false;
    }

    /**
     * @return array{key:string,label:string,purpose:string}|null
     */
    private function permission_item_for_error( \WP_Error $error ): ?array {
        $data = $error->get_error_data();
        $endpoint = is_array( $data ) ? ltrim( (string) ( $data['endpoint'] ?? '' ), '/' ) : '';
        if ( $endpoint === '' ) {
            return null;
        }

        if ( preg_match( '#^accounts/[^/]+/rules/lists(?:/.*)?$#', $endpoint ) ) {
            return [
                'key'     => 'filter_lists_edit',
                'label'   => 'Account - Filter Lists - Edit',
                'purpose' => 'Create and update SiteFort-managed edge allow and block lists.',
            ];
        }

        if ( preg_match( '#^zones/[^/]+/rulesets(?:/.*)?$#', $endpoint ) ) {
            return [
                'key'     => 'zone_waf_edit',
                'label'   => 'Zone - WAF - Edit',
                'purpose' => 'Create and update the SiteFort managed custom firewall rule.',
            ];
        }

        if ( preg_match( '#^zones/[^/]+/firewall/access_rules/rules(?:/.*)?$#', $endpoint ) ) {
            return [
                'key'     => 'access_rules_edit',
                'label'   => 'Zone - Firewall Services - Edit',
                'purpose' => 'Allow SiteFort to fall back to zone IP Access Rules if Cloudflare Lists are unavailable.',
            ];
        }

        if ( preg_match( '#^zones/[^/]+$#', $endpoint ) ) {
            return [
                'key'     => 'zone_read',
                'label'   => 'Zone - Zone - Read',
                'purpose' => 'Validate the selected zone and discover the owning account.',
            ];
        }

        return null;
    }

    private function calculate_retry_delay( int $retry_count ): int {
        $retry_count = max( 1, min( $retry_count, 8 ) );
        $delay = self::DEFAULT_RETRY_DELAY * ( 2 ** ( $retry_count - 1 ) );
        return min( $delay, 1800 );
    }

    /**
     * Temp blocks expire on a clock, not an event; without this a quiet site
     * keeps IPs blocked at the edge past their ban window.
     */
    private function schedule_temp_block_expiry_reconcile(): void {
        $temp_blocks = $this->get_temp_blocks();
        if ( empty( $temp_blocks ) ) {
            return;
        }

        $delay = min( $temp_blocks ) - time() + 5;
        $this->schedule_reconcile( (int) min( max( 60, $delay ), HOUR_IN_SECONDS ) );
    }

    /**
     * Expression IP set literal ({a b c}) capped by byte budget; $trimmed
     * reports the overflow.
     *
     * @param string[] $entries Validated IP/CIDR strings.
     */
    private function build_inline_ip_set( array $entries, int $budget, int &$trimmed ): string {
        $included = [];
        $length = 2;
        $trimmed = 0;

        foreach ( array_values( array_unique( $entries ) ) as $entry ) {
            $cost = strlen( $entry ) + 1;
            if ( $length + $cost > $budget ) {
                $trimmed++;
                continue;
            }
            $included[] = $entry;
            $length += $cost;
        }

        if ( $included === [] ) {
            $trimmed = count( array_unique( $entries ) );
            return '';
        }

        return '{' . implode( ' ', $included ) . '}';
    }

    private function acquire_lock( bool $force = false ): bool {
        global $wpdb;
        $options_table = self::table_identifier( $wpdb->options );

        $lock_option = self::LOCK_OPTION;
        $now         = time();
        // Expiry plus a unique token: a reconcile that outlives LOCK_TTL must not
        // delete a lock another process has since reclaimed.
        $lock_value  = ( $now + self::LOCK_TTL ) . ':' . wp_generate_uuid4();

        if ( $force ) {
            delete_option( $lock_option );
        }

        // Atomic acquire: INSERT IGNORE succeeds only if the row does not exist.
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $insert_sql = $wpdb->prepare(
            "INSERT IGNORE INTO {$options_table} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
            $lock_option,
            $lock_value
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex acquisition uses an atomic INSERT IGNORE on the options table.
        $inserted = $wpdb->query( $insert_sql );

        if ( $inserted ) {
            $this->sync_lock_value = $lock_value;
            return true;
        }

        // Row exists. Check if expired and atomically reclaim with WHERE.
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $lock_sql = $wpdb->prepare(
            "SELECT option_value FROM {$options_table} WHERE option_name = %s LIMIT 1",
            $lock_option
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
        $current_value = (string) $wpdb->get_var( $lock_sql );
        $locked_until  = (int) $current_value;
        if ( $locked_until < $now ) {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            $reclaim_sql = $wpdb->prepare(
                "UPDATE {$options_table} SET option_value = %s WHERE option_name = %s AND option_value = %s",
                $lock_value,
                $lock_option,
                $current_value
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex reclamation updates a single options row atomically.
            $reclaimed = $wpdb->query( $reclaim_sql );
            if ( $reclaimed > 0 ) {
                $this->sync_lock_value = $lock_value;
                return true;
            }
        }

        return false;
    }

    private function release_lock(): void {
        if ( $this->sync_lock_value === null ) {
            return;
        }

        global $wpdb;
        $options_table = self::table_identifier( $wpdb->options );

        // Delete only the exact lock we wrote; a reclaimed lock belongs to someone else.
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $delete_sql = $wpdb->prepare(
            "DELETE FROM {$options_table} WHERE option_name = %s AND option_value = %s",
            self::LOCK_OPTION,
            $this->sync_lock_value
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex release deletes only the row this process created.
        $wpdb->query( $delete_sql );
        wp_cache_delete( self::LOCK_OPTION, 'options' );
        $this->sync_lock_value = null;
    }

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

        return '`' . $table_name . '`';
    }

    /**
     * @return array<string,mixed>
     */
    private function get_settings(): array {
        $defaults = self::defaults();
        $raw = SettingsStore::get( SettingsStore::COL_CLOUDFLARE, [] );
        $settings = $this->deep_merge( $defaults, is_array( $raw ) ? $raw : [] );

        if ( ! is_array( $settings['policy'] ?? null ) ) {
            $settings['policy'] = $defaults['policy'];
        }
        if ( ! is_array( $settings['lists'] ?? null ) ) {
            $settings['lists'] = $defaults['lists'];
        }
        if ( ! is_array( $settings['sync_status'] ?? null ) ) {
            $settings['sync_status'] = $defaults['sync_status'];
        }
        if ( ! is_array( $settings['plan'] ?? null ) ) {
            $settings['plan'] = $defaults['plan'];
        }
        if ( ! is_array( $settings['limits'] ?? null ) ) {
            $settings['limits'] = $defaults['limits'];
        }

        $settings['policy'] = $this->sanitize_policy( $settings['policy'], $defaults['policy'] );
        $this->apply_scoped_list_names( $settings );
        $settings['lists']['block_id'] = sanitize_text_field( (string) $settings['lists']['block_id'] );
        $settings['lists']['allow_id'] = sanitize_text_field( (string) $settings['lists']['allow_id'] );
        $settings['lists']['pending_operations'] = $this->sanitize_text_list( $settings['lists']['pending_operations'], self::MAX_TEXT_ITEMS, 120 );
        $settings['lists']['pending_since'] = (int) ( $settings['lists']['pending_since'] ?? 0 );

        $settings['sync_status']['sync_mode'] = in_array(
            (string) $settings['sync_status']['sync_mode'],
            [ 'none', 'lists', 'access_rules_fallback', 'pending' ],
            true
        ) ? (string) $settings['sync_status']['sync_mode'] : 'none';

        $settings['sync_status']['last_error'] = sanitize_text_field( (string) $settings['sync_status']['last_error'] );
        $settings['sync_status']['last_warning'] = $this->normalize_sync_warning( (string) ( $settings['sync_status']['last_warning'] ?? '' ) );
        $settings['sync_status']['list_api_retry_after'] = (int) ( $settings['sync_status']['list_api_retry_after'] ?? 0 );
        $settings['sync_status']['list_api_retry_reason'] = sanitize_text_field( (string) ( $settings['sync_status']['list_api_retry_reason'] ?? '' ) );
        $settings['sync_status']['warning_targets'] = $this->sanitize_text_list(
            $settings['sync_status']['warning_targets'] ?? [],
            20,
            120
        );
        if ( $settings['sync_status']['last_warning'] === '' ) {
            $settings['sync_status']['warning_targets'] = [];
        }
        $settings['sync_status']['limit_warnings'] = $this->sanitize_text_list(
            $settings['sync_status']['limit_warnings'] ?? [],
            20,
            240
        );
        $settings['sync_status']['permission_check'] = $this->sanitize_permission_check(
            is_array( $settings['sync_status']['permission_check'] ?? null ) ? $settings['sync_status']['permission_check'] : []
        );
        $settings['auth_mode'] = $this->sanitize_auth_mode( (string) ( $settings['auth_mode'] ?? self::AUTH_MODE_API_TOKEN ) );
        $settings['zone_id'] = sanitize_text_field( (string) $settings['zone_id'] );
        $settings['account_id'] = sanitize_text_field( (string) $settings['account_id'] );
        $settings['plan']['key'] = sanitize_key( (string) ( $settings['plan']['key'] ?? 'unknown' ) );
        $settings['plan']['name'] = sanitize_text_field( (string) ( $settings['plan']['name'] ?? 'Unknown' ) );
        $settings['plan']['last_checked_at'] = (int) ( $settings['plan']['last_checked_at'] ?? 0 );
        $settings['limits'] = $this->sanitize_limits( is_array( $settings['limits'] ?? null ) ? $settings['limits'] : [] );
        $policy_limit_warnings = $this->apply_policy_limits( $settings['policy'], $settings['limits'] );
        $settings['sync_status']['limit_warnings'] = $this->refresh_policy_limit_warnings(
            is_array( $settings['sync_status']['limit_warnings'] ?? null ) ? $settings['sync_status']['limit_warnings'] : [],
            $policy_limit_warnings
        );
        $settings['enabled'] = ! empty( $settings['enabled'] );
        $this->apply_local_firewall_gate( $settings );
        $settings['settings_version'] = self::SETTINGS_VERSION;

        return $settings;
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function save_settings( array $settings ): bool {
        return SettingsStore::save( SettingsStore::COL_CLOUDFLARE, $settings );
    }

    /**
     * Emit a single domain hook for persisted Cloudflare config changes.
     *
     * @param array<string,mixed> $settings
     */
    private function emit_settings_saved( array $settings ): void {
        do_action( 'sitefort_settings_saved_cloudflare', $settings );
    }

    /**
     * Cloudflare Sync follows the local firewall master switch.
     *
     * @param array<string,mixed> $settings
     */
    private function apply_local_firewall_gate( array &$settings ): void {
        if ( $this->is_local_firewall_enabled() ) {
            return;
        }

        $settings['enabled'] = false;
    }

    private function is_local_firewall_enabled(): bool {
        if ( class_exists( '\\SiteFort\\Modules\\Firewall\\FirewallSettings' ) ) {
            $firewall_settings = \SiteFort\Modules\Firewall\FirewallSettings::get();
            return ! empty( $firewall_settings['enabled'] );
        }

        $firewall_settings = SettingsStore::get( SettingsStore::COL_FIREWALL, [] );
        return is_array( $firewall_settings ) && ! empty( $firewall_settings['enabled'] );
    }

    /**
     * @param array<string,mixed> $policy_input
     * @param array<string,mixed> $base
     * @return array<string,mixed>
     */
    private function sanitize_policy( array $policy_input, array $base ): array {
        $defaults = self::defaults()['policy'];
        $merged = $this->deep_merge( $defaults, $base );
        $policy = $this->deep_merge( $merged, $policy_input );

        // SiteFort owns the edge enforcement decision. Use block uniformly so
        // Cloudflare managed rules and access-rule fallback behave the same way.
        $policy['action'] = 'block';
        $policy['fallback_to_access_rules'] = ! empty( $policy['fallback_to_access_rules'] );
        $policy['sync_local_banlist'] = ! empty( $policy['sync_local_banlist'] );
        $policy['sync_local_allowlist'] = ! empty( $policy['sync_local_allowlist'] );
        $policy['auto_block_firewall_events'] = ! empty( $policy['auto_block_firewall_events'] );
        $policy['bypass_verified_bots'] = ! empty( $policy['bypass_verified_bots'] );
        $policy['block_verified_bots'] = ! empty( $policy['block_verified_bots'] );
        $policy['block_heuristic_bots'] = ! empty( $policy['block_heuristic_bots'] );

        $policy['event_threshold'] = max( 2, min( 50, (int) $policy['event_threshold'] ) );
        $policy['event_window_minutes'] = max( 1, min( 1440, (int) $policy['event_window_minutes'] ) );
        $policy['event_ban_minutes'] = max( 5, min( 10080, (int) $policy['event_ban_minutes'] ) );
        $policy['max_block_entries'] = max( self::MIN_MANAGED_EDGE_BLOCKS, min( 50000, (int) $policy['max_block_entries'] ) );
        $country_mode = sanitize_key( (string) $policy['country_mode'] );
        $policy['country_mode'] = in_array( $country_mode, [ 'off', 'block', 'allow' ], true ) ? $country_mode : 'off';
        $policy['countries'] = $this->sanitize_country_codes( $policy['countries'] ?? [] );
        $policy['networks'] = $this->sanitize_ip_entries( $policy['networks'] ?? [] );
        $policy['user_agents'] = $this->sanitize_user_agents( $policy['user_agents'] ?? [] );
        $policy['manual_block_entries'] = $this->sanitize_ip_entries( $policy['manual_block_entries'] ?? [] );
        $policy['manual_allow_entries'] = $this->sanitize_ip_entries( $policy['manual_allow_entries'] ?? [] );

        $allow_map = array_fill_keys( $policy['manual_allow_entries'], true );
        $policy['manual_block_entries'] = array_values(
            array_filter(
                $policy['manual_block_entries'],
                static fn( string $entry ): bool => ! isset( $allow_map[ $entry ] )
            )
        );
        unset(
            $policy['asns'],
            $policy['max_asns']
        );

        return $policy;
    }

    /**
     * @return array<string,mixed>
     */
    private static function defaults(): array {
        return [
            'settings_version' => self::SETTINGS_VERSION,
            'auth_mode'        => self::AUTH_MODE_API_TOKEN,
            'zone_id'          => '',
            'account_id'       => '',
            'enabled'          => false,
            'policy'           => [
                'action'                    => 'block',
                'fallback_to_access_rules'  => true,
                'sync_local_banlist'        => true,
                'sync_local_allowlist'      => true,
                'auto_block_firewall_events'=> true,
                'event_threshold'           => 3,
                'event_window_minutes'      => 10,
                'event_ban_minutes'         => 120,
                'max_block_entries'         => 5000,
                'country_mode'              => 'off',
                'countries'                 => [],
                'networks'                  => [],
                'user_agents'               => [],
                'bypass_verified_bots'      => true,
                'block_verified_bots'       => false,
                'block_heuristic_bots'      => false,
                'manual_block_entries'      => [],
                'manual_allow_entries'      => [],
            ],
            'lists' => [
                'block_name'         => self::DEFAULT_BLOCK_LIST_NAME,
                'allow_name'         => self::DEFAULT_ALLOW_LIST_NAME,
                'block_id'           => '',
                'allow_id'           => '',
                'pending_operations' => [],
                'pending_since'      => 0,
            ],
            'sync_status' => [
                'connected'          => false,
                'configured'         => false,
                'pending'            => false,
                'sync_mode'          => 'none',
                'last_sync_at'       => 0,
                'last_error'         => '',
                'last_warning'       => '',
                'list_api_retry_after' => 0,
                'list_api_retry_reason' => '',
                'retry_count'        => 0,
                'last_retry_at'      => 0,
                'block_entries'      => 0,
                'allow_entries'      => 0,
                'managed_rule_id'    => '',
                'managed_ruleset_id' => '',
                'warning_targets'    => [],
                'limit_warnings'     => [],
                'permission_check'   => self::default_permission_check(),
            ],
            'plan' => [
                'key'             => 'unknown',
                'name'            => 'Unknown',
                'last_checked_at' => 0,
            ],
            'limits' => self::default_limits_for_plan( 'unknown' ),
        ];
    }

    /**
     * @param mixed $input
     * @return string[]
     */
    private function sanitize_ip_entries( $input ): array {
        $items = $this->normalize_list_input( $input );
        $clean = [];

        foreach ( $items as $value ) {
            $normalized = $this->sanitize_ip_or_cidr( $value );
            if ( $normalized === '' ) {
                continue;
            }
            $clean[] = $normalized;
        }

        return array_values( array_slice( array_unique( $clean ), 0, 50000 ) );
    }

    /**
     * @return array<string,mixed>
     */
    private static function default_permission_check(): array {
        return [
            'status'           => 'unknown',
            'checked_at'       => 0,
            'missing_required' => [],
            'missing_optional' => [],
            'message'          => '',
        ];
    }

    /**
     * @param array<string,mixed> $input
     * @return array<string,mixed>
     */
    private function sanitize_permission_check( array $input ): array {
        $defaults = self::default_permission_check();
        $status = sanitize_key( (string) ( $input['status'] ?? 'unknown' ) );
        if ( ! in_array( $status, [ 'unknown', 'passed', 'optional_missing', 'required_missing', 'verification_failed' ], true ) ) {
            $status = 'unknown';
        }

        return [
            'status'           => $status,
            'checked_at'       => (int) ( $input['checked_at'] ?? 0 ),
            'missing_required' => $this->sanitize_permission_items( $input['missing_required'] ?? [] ),
            'missing_optional' => $this->sanitize_permission_items( $input['missing_optional'] ?? [] ),
            'message'          => sanitize_text_field( (string) ( $input['message'] ?? $defaults['message'] ) ),
        ];
    }

    /**
     * @param mixed $items
     * @return array<int,array<string,string>>
     */
    private function sanitize_permission_items( $items ): array {
        if ( ! is_array( $items ) ) {
            return [];
        }

        $clean = [];
        foreach ( $items as $item ) {
            if ( ! is_array( $item ) ) {
                continue;
            }

            $label = sanitize_text_field( (string) ( $item['label'] ?? '' ) );
            if ( $label === '' ) {
                continue;
            }

            $clean[] = [
                'key'     => sanitize_key( (string) ( $item['key'] ?? '' ) ),
                'label'   => $label,
                'purpose' => sanitize_text_field( (string) ( $item['purpose'] ?? '' ) ),
            ];
        }

        return array_values( array_slice( $clean, 0, 8 ) );
    }

    /**
     * @return array<string,mixed>
     */
    private function audit_permissions( Api $api, bool $include_optional = true ): array {
        $checks = [
            [
                'key'      => 'zone_read',
                'label'    => 'Zone - Zone - Read',
                'purpose'  => 'Validate the selected zone and discover the owning account.',
                'required' => true,
                'probe'    => static fn() => $api->get_zone_details(),
            ],
            [
                'key'      => 'filter_lists_edit',
                'label'    => 'Account - Filter Lists - Edit',
                'purpose'  => 'Create and update SiteFort-managed edge allow and block lists.',
                'required' => true,
                'probe'    => static fn() => $api->list_lists( 1, 1 ),
            ],
            [
                'key'      => 'zone_waf_edit',
                'label'    => 'Zone - WAF - Edit',
                'purpose'  => 'Create and update the SiteFort managed custom firewall rule.',
                'required' => true,
                'probe'    => static function() use ( $api ) {
                    $result = $api->get_custom_firewall_entrypoint();
                    // A zone that never had a custom rule has no entrypoint
                    // ruleset yet; that 404 is not a permission failure.
                    if ( is_wp_error( $result ) && Api::is_missing_entrypoint_error( $result ) ) {
                        return [ 'result' => null ];
                    }
                    return $result;
                },
            ],
        ];

        if ( $include_optional ) {
            $checks[] = [
                'key'      => 'access_rules_edit',
                'label'    => 'Zone - Firewall Services - Edit',
                'purpose'  => 'Allow SiteFort to fall back to zone IP Access Rules if Cloudflare Lists are unavailable.',
                'required' => false,
                'probe'    => static fn() => $api->get_access_rules(
                    [
                        'page'     => 1,
                        'per_page' => 1,
                    ]
                ),
            ];
        }

        $missing_required = [];
        $missing_optional = [];
        $verification_errors = [];

        foreach ( $checks as $check ) {
            $result = $check['probe']();
            if ( ! is_wp_error( $result ) ) {
                continue;
            }

            if ( $this->error_looks_like_permission_issue( $result ) ) {
                $item = [
                    'key'     => $check['key'],
                    'label'   => $check['label'],
                    'purpose' => $check['purpose'],
                ];
                if ( $check['required'] ) {
                    $missing_required[] = $item;
                } else {
                    $missing_optional[] = $item;
                }
                continue;
            }

            $verification_errors[] = sanitize_text_field( $result->get_error_message() );
        }

        $audit = self::default_permission_check();
        $audit['checked_at'] = time();
        $audit['missing_required'] = $missing_required;
        $audit['missing_optional'] = $missing_optional;

        if ( ! empty( $verification_errors ) ) {
            $audit['status'] = 'verification_failed';
            $audit['message'] = 'Cloudflare authenticated, but SiteFort could not verify the required API access. ' . $verification_errors[0];
            return $audit;
        }

        if ( ! empty( $missing_required ) ) {
            $audit['status'] = 'required_missing';
            $labels = implode( ', ', wp_list_pluck( $missing_required, 'label' ) );
            $audit['message'] = 'Cloudflare authenticated, but the token is missing required permissions: ' . $labels . '.';
            return $audit;
        }

        if ( ! empty( $missing_optional ) ) {
            $audit['status'] = 'optional_missing';
            $labels = implode( ', ', wp_list_pluck( $missing_optional, 'label' ) );
            $audit['message'] = 'Cloudflare connected, but the fallback permission is unavailable: ' . $labels . '.';
            return $audit;
        }

        $audit['status'] = 'passed';
        return $audit;
    }

    private function error_looks_like_permission_issue( \WP_Error $error ): bool {
        $data = $error->get_error_data();
        $http_code = is_array( $data ) ? (int) ( $data['code'] ?? 0 ) : 0;
        if ( $http_code === 401 || $http_code === 403 ) {
            return true;
        }

        $message = strtolower( $error->get_error_message() );
        foreach ( [ 'permission', 'permissions', 'scope', 'scopes', 'forbidden', 'not authorized', 'access denied' ] as $needle ) {
            if ( str_contains( $message, $needle ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param mixed $input
     * @return string[]
     */
    private function sanitize_country_codes( $input ): array {
        $items = $this->normalize_list_input( $input );
        $clean = [];

        foreach ( $items as $value ) {
            $code = strtoupper( sanitize_text_field( trim( $value ) ) );
            if ( preg_match( '/^[A-Z]{2}$/', $code ) !== 1 ) {
                continue;
            }
            $clean[] = $code;
        }

        return array_values( array_slice( array_unique( $clean ), 0, self::MAX_COUNTRIES ) );
    }

    /**
     * @param mixed $input
     * @return string[]
     */
    private function sanitize_user_agents( $input ): array {
        $items = $this->normalize_list_input( $input );
        $clean = [];

        foreach ( $items as $value ) {
            $value = strtolower( sanitize_text_field( trim( $value ) ) );
            if ( $value === '' ) {
                continue;
            }
            $clean[] = substr( $value, 0, 120 );
        }

        return array_values( array_slice( array_unique( $clean ), 0, self::MAX_USER_AGENTS ) );
    }

    private function normalize_sync_warning( string $warning ): string {
        $warning = sanitize_text_field( $warning );
        if ( $warning === '' ) {
            return '';
        }

        if ( str_starts_with( $warning, 'A duplicate Cloudflare access rule already exists for ' ) ) {
            return '';
        }

        return $warning;
    }

    /**
     * @param mixed $input
     * @return string[]
     */
    private function sanitize_text_list( $input, int $max_items, int $max_len ): array {
        $items = $this->normalize_list_input( $input );
        $clean = [];
        foreach ( $items as $item ) {
            $item = sanitize_text_field( trim( $item ) );
            if ( $item === '' ) {
                continue;
            }
            $clean[] = substr( $item, 0, $max_len );
        }

        return array_values( array_slice( array_unique( $clean ), 0, $max_items ) );
    }

    private function sanitize_list_name( string $name, string $fallback ): string {
        $name = Api::sanitize_list_name( $name );
        return $name === '' ? Api::sanitize_list_name( $fallback ) : $name;
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function apply_scoped_list_names( array &$settings ): void {
        $resolved_block = $this->resolve_scoped_list_name( 'block', $settings );
        $resolved_allow = $this->resolve_scoped_list_name( 'allow', $settings );

        if ( (string) ( $settings['lists']['block_name'] ?? '' ) !== $resolved_block ) {
            $settings['lists']['block_name'] = $resolved_block;
            $settings['lists']['block_id'] = '';
        }

        if ( (string) ( $settings['lists']['allow_name'] ?? '' ) !== $resolved_allow ) {
            $settings['lists']['allow_name'] = $resolved_allow;
            $settings['lists']['allow_id'] = '';
        }
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function resolve_scoped_list_name( string $kind, array $settings ): string {
        $kind = $kind === 'allow' ? 'allow' : 'block';
        $prefix = $kind === 'allow' ? 'sitefort_allow' : 'sitefort_block';
        $zone_id = preg_replace( '/[^a-z0-9]/', '', strtolower( (string) ( $settings['zone_id'] ?? '' ) ) ) ?: 'zone';
        $zone_suffix = substr( $zone_id, 0, 8 );
        $host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
        $host = preg_replace( '/[^a-z0-9]+/', '_', $host ) ?: 'site';
        $host = trim( (string) $host, '_' );
        $host = substr( $host, 0, 20 );

        return $this->sanitize_list_name(
            sprintf( '%s_%s_%s', $prefix, $host, $zone_suffix ),
            $kind === 'allow' ? self::DEFAULT_ALLOW_LIST_NAME : self::DEFAULT_BLOCK_LIST_NAME
        );
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function build_access_rule_owner_tag( array $settings ): string {
        $zone_id = preg_replace( '/[^a-z0-9]/', '', strtolower( (string) ( $settings['zone_id'] ?? '' ) ) ) ?: 'zone';
        $site_hash = substr( md5( home_url( '/' ) ), 0, 8 );
        return substr( 'z' . substr( $zone_id, 0, 6 ) . '_' . $site_hash, 0, 24 );
    }

    private function sanitize_ip_or_cidr( string $value ): string {
        $value = trim( $value );
        if ( $value === '' ) {
            return '';
        }

        if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
            return $this->normalize_ip( $value );
        }

        if ( strpos( $value, '/' ) === false ) {
            return '';
        }

        [ $subnet, $bits ] = explode( '/', $value, 2 );
        $subnet = trim( $subnet );
        $bits = trim( $bits );

        if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) || ! ctype_digit( $bits ) ) {
            return '';
        }

        $bits_int = (int) $bits;
        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            if ( $bits_int < 0 || $bits_int > 32 ) {
                return '';
            }
        } else {
            if ( $bits_int < 0 || $bits_int > 128 ) {
                return '';
            }
        }

        return $this->normalize_ip( $subnet ) . '/' . $bits_int;
    }

    private function normalize_ip( string $ip ): string {
        $ip = trim( $ip );
        if ( stripos( $ip, '::ffff:' ) === 0 ) {
            $mapped = substr( $ip, 7 );
            if ( filter_var( $mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
                return $mapped;
            }
        }
        return $ip;
    }

    private function is_public_ip_or_cidr( string $entry ): bool {
        if ( strpos( $entry, '/' ) === false ) {
            return filter_var(
                $entry,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            ) !== false;
        }

        [ $subnet ] = explode( '/', $entry, 2 );
        return filter_var(
            $subnet,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        ) !== false;
    }

    /**
     * @param mixed $input
     * @return string[]
     */
    private function normalize_list_input( $input ): array {
        if ( is_string( $input ) ) {
            $decoded = json_decode( $input, true );
            if ( is_array( $decoded ) ) {
                $input = $decoded;
            }
        }

        if ( is_array( $input ) ) {
            $flat = [];
            foreach ( $input as $item ) {
                if ( is_array( $item ) ) {
                    foreach ( $item as $nested ) {
                        if ( is_scalar( $nested ) ) {
                            $flat[] = (string) $nested;
                        }
                    }
                    continue;
                }
                if ( is_scalar( $item ) ) {
                    $flat[] = (string) $item;
                }
            }
            return $this->split_lines_and_commas( $flat );
        }

        if ( is_scalar( $input ) ) {
            return $this->split_lines_and_commas( [ (string) $input ] );
        }

        return [];
    }

    /**
     * @param string[] $values
     * @return string[]
     */
    private function split_lines_and_commas( array $values ): array {
        $items = [];
        foreach ( $values as $value ) {
            $parts = preg_split( '/[\r\n,]+/', $value, -1, PREG_SPLIT_NO_EMPTY );
            if ( ! is_array( $parts ) ) {
                continue;
            }
            foreach ( $parts as $part ) {
                $part = trim( $part );
                if ( $part !== '' ) {
                    $items[] = $part;
                }
            }
        }
        return $items;
    }

    /**
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $value
     * @return array<string,mixed>
     */
    private function deep_merge( array $defaults, array $value ): array {
        $merged = $defaults;
        foreach ( $value as $key => $item ) {
            if ( is_array( $item ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
                $merged[ $key ] = $this->deep_merge( $merged[ $key ], $item );
            } else {
                $merged[ $key ] = $item;
            }
        }

        return $merged;
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function reset_managed_state( array &$settings ): void {
        $settings['account_id'] = '';
        $settings['lists']['block_id'] = '';
        $settings['lists']['allow_id'] = '';
        $this->clear_pending_operations( $settings );
        $settings['sync_status']['managed_rule_id'] = '';
        $settings['sync_status']['managed_ruleset_id'] = '';
        $settings['sync_status']['sync_mode'] = 'none';
        $settings['sync_status']['pending'] = false;
        $settings['sync_status']['limit_warnings'] = [];
    }

    /**
     * @return string[]
     */
    private function get_local_lockout_entries( string $key ): array {
        $firewall = SettingsStore::get( SettingsStore::COL_FIREWALL );
        $raw = (string) ( $firewall[ $key ] ?? '' );
        $lines = preg_split( '/[\r\n,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
        if ( ! is_array( $lines ) ) {
            return [];
        }

        $entries = [];
        foreach ( $lines as $line ) {
            $entry = $this->sanitize_ip_or_cidr( (string) $line );
            if ( $entry === '' ) {
                continue;
            }
            $entries[] = $entry;
        }

        return array_values( array_unique( $entries ) );
    }

    /**
     * @return array<string,int>
     */
    private function get_temp_blocks(): array {
        $stored = get_option( self::TEMP_BLOCKS_OPTION, [] );
        if ( ! is_array( $stored ) ) {
            return [];
        }

        $clean = [];
        foreach ( $stored as $entry => $expires_at ) {
            $entry = $this->sanitize_ip_or_cidr( (string) $entry );
            $expires_at = (int) $expires_at;
            if ( $entry === '' || $expires_at <= time() ) {
                continue;
            }
            $clean[ $entry ] = $expires_at;
        }

        return $clean;
    }

    private function prune_temp_blocks(): void {
        $this->run_with_data_lock(
            function(): void {
                $temp = $this->get_temp_blocks();
                update_option( self::TEMP_BLOCKS_OPTION, $temp, false );
            }
        );
    }

    private function add_temp_block_unlocked( string $entry, int $expires_at ): void {
        $temp = $this->get_temp_blocks();
        $temp[ $entry ] = max( time() + 60, $expires_at );
        if ( count( $temp ) > self::MAX_TEMP_BLOCKS ) {
            asort( $temp );
            $temp = array_slice( $temp, -self::MAX_TEMP_BLOCKS, null, true );
        }

        update_option( self::TEMP_BLOCKS_OPTION, $temp, false );
    }

    private function remove_temp_block_unlocked( string $entry ): void {
        $temp = $this->get_temp_blocks();
        if ( ! isset( $temp[ $entry ] ) ) {
            return;
        }

        unset( $temp[ $entry ] );
        update_option( self::TEMP_BLOCKS_OPTION, $temp, false );
    }

    private function clear_runtime_block_state( string $entry ): void {
        $entry = $this->sanitize_ip_or_cidr( $entry );
        if ( $entry === '' ) {
            return;
        }

        $this->run_with_data_lock(
            function() use ( $entry ): void {
                $this->clear_runtime_block_state_unlocked( $entry );
            }
        );
    }

    private function clear_runtime_block_state_unlocked( string $entry ): void {
        $this->remove_temp_block_unlocked( $entry );
        $this->clear_event_hit_unlocked( $entry );
    }

    private function clear_event_hit_unlocked( string $entry ): void {
        $hits = get_option( self::EVENT_HITS_OPTION, [] );
        if ( ! is_array( $hits ) || ! isset( $hits[ $entry ] ) ) {
            return;
        }

        unset( $hits[ $entry ] );
        update_option( self::EVENT_HITS_OPTION, $hits, false );
    }

    /**
     * @param array<string,mixed> $policy
     */
    private function record_event_hit( string $ip, array $policy ): bool {
        return (bool) $this->run_with_data_lock(
            function() use ( $ip, $policy ): bool {
                $hits = get_option( self::EVENT_HITS_OPTION, [] );
                if ( ! is_array( $hits ) ) {
                    $hits = [];
                }

                $window = max( 60, (int) $policy['event_window_minutes'] * MINUTE_IN_SECONDS );
                $threshold = max( 2, (int) $policy['event_threshold'] );
                $ban_seconds = max( 300, (int) $policy['event_ban_minutes'] * MINUTE_IN_SECONDS );
                $now = time();

                $entry = $hits[ $ip ] ?? [
                    'count'      => 0,
                    'first_seen' => $now,
                    'last_seen'  => $now,
                ];

                $first_seen = (int) ( $entry['first_seen'] ?? $now );
                if ( ( $now - $first_seen ) > $window ) {
                    $entry = [
                        'count'      => 0,
                        'first_seen' => $now,
                        'last_seen'  => $now,
                    ];
                }

                $entry['count'] = (int) ( $entry['count'] ?? 0 ) + 1;
                $entry['last_seen'] = $now;
                $hits[ $ip ] = $entry;

                // Keep recent hit buckets only.
                if ( count( $hits ) > self::MAX_EVENT_HITS ) {
                    uasort(
                        $hits,
                        static fn( array $a, array $b ): int => (int) ( $a['last_seen'] ?? 0 ) <=> (int) ( $b['last_seen'] ?? 0 )
                    );
                    $hits = array_slice( $hits, -self::MAX_EVENT_HITS, null, true );
                }

                $promoted = false;
                if ( $entry['count'] >= $threshold ) {
                    $this->add_temp_block_unlocked( $ip, $now + $ban_seconds );
                    unset( $hits[ $ip ] );
                    $promoted = true;
                }

                update_option( self::EVENT_HITS_OPTION, $hits, false );

                return $promoted;
            }
        );
    }

    /**
     * @template T
     * @param callable():T $callback
     * @return T
     */
    private function run_with_data_lock( callable $callback ) {
        $token = $this->acquire_data_lock();
        if ( $token === null ) {
            return $callback();
        }

        try {
            return $callback();
        } finally {
            $this->release_data_lock( $token );
        }
    }

    private function acquire_data_lock(): ?string {
        $token = wp_generate_uuid4();
        if ( $token === '' ) {
            $token = uniqid( 'sitefort_cf_', true );
        }
        $now = time();

        for ( $attempt = 0; $attempt < self::DATA_LOCK_MAX_ATTEMPTS; $attempt++ ) {
            $lock = [
                'token'      => $token,
                'expires_at' => $now + self::DATA_LOCK_TTL,
            ];

            if ( add_option( self::DATA_LOCK_OPTION, $lock, '', 'no' ) ) {
                return $token;
            }

            $existing = get_option( self::DATA_LOCK_OPTION, [] );
            $expires_at = is_array( $existing ) ? (int) ( $existing['expires_at'] ?? 0 ) : 0;
            if ( $expires_at > 0 && $expires_at <= time() ) {
                delete_option( self::DATA_LOCK_OPTION );
                continue;
            }

            if ( function_exists( 'usleep' ) ) {
                usleep( self::DATA_LOCK_WAIT_US );
            }
        }

        return null;
    }

    private function release_data_lock( string $token ): void {
        $existing = get_option( self::DATA_LOCK_OPTION, [] );
        $existing_token = is_array( $existing ) ? (string) ( $existing['token'] ?? '' ) : '';
        if ( hash_equals( $existing_token, $token ) ) {
            delete_option( self::DATA_LOCK_OPTION );
        }
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function maybe_refresh_plan_profile( array &$settings, Api $api ): void {
        $last_checked = (int) ( $settings['plan']['last_checked_at'] ?? 0 );
        if ( $last_checked > 0 && ( time() - $last_checked ) < DAY_IN_SECONDS ) {
            return;
        }
        $this->refresh_plan_profile( $settings, $api );
    }

    /**
     * @param array<string,mixed> $settings
     */
    private function refresh_plan_profile( array &$settings, Api $api ): void {
        $zone = $api->get_zone_details();
        if ( is_wp_error( $zone ) ) {
            return;
        }

        $plan = is_array( $zone['result']['plan'] ?? null ) ? $zone['result']['plan'] : [];
        $plan_key = sanitize_key(
            strtolower(
                (string) (
                    $plan['legacy_id']
                    ?? $plan['id']
                    ?? $plan['name']
                    ?? 'unknown'
                )
            )
        );

        $normalized_key = $this->normalize_plan_key( $plan_key, (string) ( $plan['name'] ?? '' ) );
        $plan_name = sanitize_text_field( (string) ( $plan['name'] ?? ucfirst( $normalized_key ) ) );
        if ( $plan_name === '' ) {
            $plan_name = ucfirst( $normalized_key );
        }

        $settings['plan'] = [
            'key'             => $normalized_key,
            'name'            => $plan_name,
            'last_checked_at' => time(),
        ];
        $settings['limits'] = self::default_limits_for_plan( $normalized_key );
    }

    private function normalize_plan_key( string $raw_key, string $name ): string {
        $needle = strtolower( $raw_key . ' ' . $name );
        if ( str_contains( $needle, 'enterprise' ) ) {
            return 'enterprise';
        }
        if ( str_contains( $needle, 'business' ) || str_contains( $needle, 'biz' ) ) {
            return 'business';
        }
        if ( str_contains( $needle, 'pro' ) ) {
            return 'pro';
        }
        if ( str_contains( $needle, 'free' ) ) {
            return 'free';
        }
        return 'unknown';
    }

    /**
     * @return array<string,int>
     */
    private static function default_limits_for_plan( string $plan_key ): array {
        $plan_key = sanitize_key( $plan_key );
        $limits = [
            'max_block_entries'  => 5000,
            'max_allow_entries'  => 5000,
            'max_countries'      => self::MAX_COUNTRIES,
            'max_user_agents'    => self::MAX_USER_AGENTS,
            'max_manual_entries' => 5000,
        ];

        if ( $plan_key === 'free' ) {
            $limits['max_block_entries']  = 1000;
            $limits['max_allow_entries']  = 1000;
            $limits['max_user_agents']    = 80;
            $limits['max_manual_entries'] = 1000;
            return $limits;
        }

        if ( $plan_key === 'pro' ) {
            $limits['max_block_entries']  = 3000;
            $limits['max_allow_entries']  = 3000;
            $limits['max_user_agents']    = 120;
            $limits['max_manual_entries'] = 3000;
            return $limits;
        }

        if ( $plan_key === 'business' ) {
            $limits['max_block_entries']  = 20000;
            $limits['max_allow_entries']  = 20000;
            $limits['max_user_agents']    = 200;
            $limits['max_manual_entries'] = 10000;
            return $limits;
        }

        if ( $plan_key === 'enterprise' ) {
            $limits['max_block_entries']  = 50000;
            $limits['max_allow_entries']  = 50000;
            $limits['max_user_agents']    = 500;
            $limits['max_manual_entries'] = 50000;
            return $limits;
        }

        return $limits;
    }

    /**
     * @param array<string,mixed> $limits
     * @return array<string,int>
     */
    private function sanitize_limits( array $limits ): array {
        $defaults = self::default_limits_for_plan( 'unknown' );
        foreach ( $defaults as $key => $value ) {
            $defaults[ $key ] = max( 10, min( 100000, (int) ( $limits[ $key ] ?? $value ) ) );
        }
        return $defaults;
    }

    /**
     * @param array<string,mixed> $settings
     * @param string[]            $block_entries
     * @param string[]            $allow_entries
     * @return array{block:string[],allow:string[],warnings:string[]}
     */
    private function apply_plan_limits( array $settings, array $block_entries, array $allow_entries ): array {
        $limits = $settings['limits'];
        $warnings = [];

        $policy_max = max( self::MIN_MANAGED_EDGE_BLOCKS, min( 50000, (int) ( $settings['policy']['max_block_entries'] ?? 5000 ) ) );
        $block_cap = min( $policy_max, (int) ( $limits['max_block_entries'] ?? $policy_max ) );
        $allow_cap = min( $policy_max, (int) ( $limits['max_allow_entries'] ?? $policy_max ) );

        if ( count( $block_entries ) > $block_cap ) {
            $warnings[] = sprintf(
                'Block entries truncated to %d (current plan cap).',
                $block_cap
            );
            $block_entries = array_slice( $block_entries, 0, $block_cap );
        }

        if ( count( $allow_entries ) > $allow_cap ) {
            $warnings[] = sprintf(
                'Allow entries truncated to %d (current plan cap).',
                $allow_cap
            );
            $allow_entries = array_slice( $allow_entries, 0, $allow_cap );
        }

        return [
            'block'    => $block_entries,
            'allow'    => $allow_entries,
            'warnings' => $warnings,
        ];
    }

    /**
     * @param array<string,mixed> $policy
     * @param array<string,int>   $limits
     * @return string[] warnings
     */
    private function apply_policy_limits( array &$policy, array $limits ): array {
        $warnings = [];

        $block_cap = max( self::MIN_MANAGED_EDGE_BLOCKS, (int) ( $limits['max_block_entries'] ?? 5000 ) );
        $requested_block_cap = max( self::MIN_MANAGED_EDGE_BLOCKS, min( 50000, (int) ( $policy['max_block_entries'] ?? 5000 ) ) );
        if ( $requested_block_cap > $block_cap ) {
            $policy['max_block_entries'] = $block_cap;
            $warnings[] = sprintf( 'Max edge blocks reduced to %d (current plan cap).', $block_cap );
        } else {
            $policy['max_block_entries'] = $requested_block_cap;
        }

        $manual_cap = max( 10, (int) ( $limits['max_manual_entries'] ?? 5000 ) );
        if ( count( $policy['manual_block_entries'] ?? [] ) > $manual_cap ) {
            $policy['manual_block_entries'] = array_slice( $policy['manual_block_entries'], 0, $manual_cap );
            $warnings[] = sprintf( 'Manual block entries trimmed to %d (plan cap).', $manual_cap );
        }

        if ( count( $policy['manual_allow_entries'] ?? [] ) > $manual_cap ) {
            $policy['manual_allow_entries'] = array_slice( $policy['manual_allow_entries'], 0, $manual_cap );
            $warnings[] = sprintf( 'Manual allow entries trimmed to %d (plan cap).', $manual_cap );
        }

        return $warnings;
    }

    /**
     * Replace policy-derived limit warnings while preserving runtime sync warnings.
     *
     * @param string[] $existing_warnings
     * @param string[] $policy_warnings
     * @return string[]
     */
    private function refresh_policy_limit_warnings( array $existing_warnings, array $policy_warnings ): array {
        $preserved = array_values(
            array_filter(
                $this->sanitize_text_list( $existing_warnings, 20, 240 ),
                fn( string $warning ): bool => ! $this->is_policy_limit_warning( $warning )
            )
        );

        return array_values(
            array_unique(
                array_merge(
                    $preserved,
                    $this->sanitize_text_list( $policy_warnings, 20, 240 )
                )
            )
        );
    }

    private function is_policy_limit_warning( string $warning ): bool {
        $policy_prefixes = [
            'Max edge blocks reduced to ',
            'Manual block entries trimmed to ',
            'Manual allow entries trimmed to ',
        ];

        foreach ( $policy_prefixes as $prefix ) {
            if ( strpos( $warning, $prefix ) === 0 ) {
                return true;
            }
        }

        return false;
    }

    private function escape_expression_string( string $value ): string {
        return str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $value );
    }

    private function mask_token_preview( string $token ): string {
        if ( $token === '' ) {
            return '';
        }
        if ( strlen( $token ) <= 8 ) {
            return '********';
        }
        return substr( $token, 0, 4 ) . str_repeat( '*', 12 ) . substr( $token, -4 );
    }
}
