<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall\API;

use SiteFort\Includes\AdminFilesystemCredentials;
use SiteFort\Includes\IpDetector;
use SiteFort\Includes\IpProbe;
use SiteFort\Includes\IpUtils;
use SiteFort\Includes\ProxyPresets;
use SiteFort\Includes\SettingsStore;
use SiteFort\Modules\Cloudflare\Manager as CloudflareManager;
use SiteFort\Modules\Firewall\Blocklist\BlocklistService;
use SiteFort\Modules\Firewall\ExtendedProtection\Manager as ExtendedProtectionManager;
use SiteFort\Modules\Firewall\FirewallBypass;
use SiteFort\Modules\Firewall\RuleEngine;
use SiteFort\Modules\Firewall\FirewallConfigService;
use SiteFort\Modules\Firewall\FirewallLogger;
use SiteFort\Modules\Firewall\FirewallSettings;
use SiteFort\Modules\Firewall\GeoIP\GeoIPService;
use SiteFort\Modules\Firewall\GeoIP\MaxMindDownloader;
use SiteFort\Modules\Firewall\LockoutManager;

use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;

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

class FirewallApi {
    private const USER_INI_ADVISORY_ACK_KEY = 'waf_user_ini_advisory_ack';

    public function register_routes() {
        // --- Existing routes ---

        register_rest_route( 'sitefort/v1', '/firewall/settings', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_settings' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'update_settings' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

         register_rest_route( 'sitefort/v1', '/firewall/ip', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_detected_ip' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/ip/diagnose', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'diagnose_ip' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/proxy-presets', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_proxy_presets' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        // --- GeoIP routes ---

        register_rest_route( 'sitefort/v1', '/firewall/geoip/settings', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_geoip_settings' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'update_geoip_settings' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/geoip/credentials', [
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_geoip_credentials' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );
        register_rest_route( 'sitefort/v1', '/firewall/geoip/update-db', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'update_geoip_db' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/geoip/lookup', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'geoip_lookup' ],
                'permission_callback' => [ $this, 'check_permission' ],
                'args'                => [
                    'ip' => [
                        'required'          => true,
                        'type'              => 'string',
                        // Validation runs before sanitization, so trim here to
                        // accept copy-pasted values with surrounding whitespace.
                        'validate_callback' => function( $param ) {
                            return filter_var( trim( (string) $param ), FILTER_VALIDATE_IP ) !== false;
                        },
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                ],
            ],
        ] );

        // --- Blocklist routes ---

        register_rest_route( 'sitefort/v1', '/firewall/blocklist/settings', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_blocklist_settings' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'update_blocklist_settings' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/blocklist/sync', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'sync_blocklist' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/blocklist/stats', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_blocklist_stats' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/extended-protection', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_extended_protection_status' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'update_extended_protection' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/extended-protection/user-ini-advisory/acknowledge', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'acknowledge_extended_protection_user_ini_advisory' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        // --- IP Lockout management routes ---

        register_rest_route( 'sitefort/v1', '/firewall/lockouts', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_lockouts' ],
                'permission_callback' => [ $this, 'check_permission' ],
                'args'                => [
                    'type' => [
                        'required' => false,
                        'type'     => 'string',
                        'enum'     => [ 'ban', 'allow' ],
                    ],
                ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'add_lockout' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/lockouts/remove', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'remove_lockout' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/lockouts/purge-expired', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'purge_expired_lockouts' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        // --- IP probe routes ---

        // Public endpoint: receives the IntelEngine callback. No auth required
        // because IntelEngine cannot authenticate as a WP admin. Security is
        // provided by the one-time token validated inside the handler.
        register_rest_route( 'sitefort/v1', '/firewall/ip-probe', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'handle_ip_probe_callback' ],
                'permission_callback' => '__return_true',
                'args'                => [
                    'token' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'ip' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/ip-probe/status', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_ip_probe_status' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/ip-probe/trigger', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'trigger_ip_probe' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );

        // --- Firewall event log ---

        register_rest_route( 'sitefort/v1', '/firewall/events', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_events' ],
                'permission_callback' => [ $this, 'check_permission' ],
                'args'                => [
                    'per_page'    => [ 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 200 ],
                    'page'        => [ 'type' => 'integer', 'default' => 1,  'minimum' => 1 ],
                    'attack_type' => [ 'type' => 'string',  'default' => '' ],
                    'ip'          => [ 'type' => 'string',  'default' => '' ],
                    'since'       => [ 'type' => 'integer', 'default' => 0 ],
                    'until'       => [ 'type' => 'integer', 'default' => 0 ],
                ],
            ],
        ] );

        register_rest_route( 'sitefort/v1', '/firewall/events/clear', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'clear_events' ],
                'permission_callback' => [ $this, 'check_permission' ],
            ],
        ] );
    }

    public function check_permission() {
        return \SiteFort\Includes\Capabilities::can_manage_plugin();
    }

    /**
     * GET /sitefort/v1/firewall/events
     *
     * Returns paginated firewall block events with optional filters.
     * Response shape mirrors DashboardLogEntry so the feed can reuse types.
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function get_events( WP_REST_Request $request ): WP_REST_Response {
        if ( ! FirewallLogger::is_schema_current() ) {
            return new WP_REST_Response( [
                'items' => [],
                'total' => 0,
                'pages' => 1,
            ] );
        }

        global $wpdb;
        $table = self::table_identifier( $wpdb->prefix . 'sitefort_firewall_events' );

        $per_page    = (int) $request->get_param( 'per_page' );
        $page        = (int) $request->get_param( 'page' );
        $attack_type = sanitize_text_field( $request->get_param( 'attack_type' ) );
        $ip          = sanitize_text_field( $request->get_param( 'ip' ) );
        $since       = (int) $request->get_param( 'since' );
        $until       = (int) $request->get_param( 'until' );

        $where  = [ 'blog_id = %d' ];
        $params = [ get_current_blog_id() ];

        if ( $since > 0 ) {
            $where[]  = 'event_time >= %d';
            $params[] = $since;
        }
        if ( $until > 0 ) {
            $where[]  = 'event_time <= %d';
            $params[] = $until;
        }
        if ( $attack_type !== '' ) {
            $where[]  = 'attack_type LIKE %s';
            $params[] = '%' . $wpdb->esc_like( $attack_type ) . '%';
        }
        if ( $ip !== '' ) {
            $where[]  = 'ip_address LIKE %s';
            $params[] = $wpdb->esc_like( $ip ) . '%';
        }

        $where_sql = 'WHERE ' . implode( ' AND ', $where );
        $offset    = ( $page - 1 ) * $per_page;

        // Total count for pagination headers.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $total = (int) $wpdb->get_var(
            $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter

        // Fetch page.
        $limit_params = array_merge( $params, [ $per_page, $offset ] );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, event_time, ip_address, request_uri, http_method, http_status, attack_type, user_agent
                 FROM {$table} {$where_sql} ORDER BY event_time DESC LIMIT %d OFFSET %d",
                ...$limit_params
            ),
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,PluginCheck.Security.DirectDB.UnescapedDBParameter

        $items = [];
        if ( is_array( $rows ) ) {
            foreach ( $rows as $row ) {
                $attack = (string) $row['attack_type'];
                $items[] = [
                    'id'          => (int) $row['id'],
                    'timestamp'   => gmdate( 'Y-m-d\TH:i:s\Z', (int) $row['event_time'] ),
                    'event_time'  => (int) $row['event_time'],
                    'ip_address'  => (string) $row['ip_address'],
                    'request_uri' => (string) $row['request_uri'],
                    'http_method' => (string) $row['http_method'],
                    'http_status' => (int) $row['http_status'],
                    'attack_type' => $attack,
                    'severity'    => \SiteFort\Modules\Firewall\FirewallLogger::get_severity( $attack ),
                    'user_agent'  => (string) $row['user_agent'],
                ];
            }
        }

        $pages    = max( 1, (int) ceil( $total / $per_page ) );
        $response = new WP_REST_Response( [
            'items' => $items,
            'total' => $total,
            'pages' => $pages,
        ], 200 );

        $response->header( 'X-WP-Total',      (string) $total );
        $response->header( 'X-WP-TotalPages', (string) $pages );

        return $response;
    }

    /**
     * POST /sitefort/v1/firewall/events/clear
     *
     * Time-budgeted; reports `remaining` so the app keeps calling until drained.
     */
    public function clear_events(): WP_REST_Response {
        $result = FirewallLogger::clear_events( get_current_blog_id() );
        if ( $result === null ) {
            return new WP_REST_Response( [
                'success' => false,
                'message' => 'Log maintenance is currently running. Retry in a moment.',
            ], 503 );
        }

        // Drop pending WAF file logs too or the next ingest refills the table.
        // The files are not blog-scoped; main site only.
        if ( ! is_multisite() || is_main_site() ) {
            $data_dir = WP_CONTENT_DIR . '/sitefort-data';
            $pending  = glob( $data_dir . '/waf-blocks.*.php' ) ?: [];
            $pending[] = $data_dir . '/waf-blocks.php';
            foreach ( $pending as $file ) {
                if ( is_file( $file ) ) {
                    wp_delete_file( $file );
                }
            }
        }

        if ( $result['deleted'] > 0 ) {
            \SiteFort\Modules\AuditLog\AuditLog::add_log(
                'Firewall Log Cleared',
                [
                    'deleted_events'   => $result['deleted'],
                    'remaining_events' => $result['remaining'],
                ],
                'warning',
                'firewall'
            );
        }

        return new WP_REST_Response( [
            'success'   => true,
            'deleted'   => $result['deleted'],
            'remaining' => $result['remaining'],
        ], 200 );
    }

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

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

    // =========================================================================
    // Existing endpoints
    // =========================================================================

    public function get_settings() {
        $redacted = FirewallSettings::redact_sensitive_keys( $this->get_effective_firewall_settings() );

        return new WP_REST_Response( $this->with_computed_settings_flags( $redacted ) );
    }

    /**
     * Decorate redacted settings with read-only computed flags returned by both the
     * GET and the update responses so the UI sees the same shape either way.
     *
     * @param array<string, mixed> $redacted
     * @return array<string, mixed>
     */
    private function with_computed_settings_flags( array $redacted ): array {
        $waf_status = $this->get_extended_waf_status();
        $redacted['extended_waf_active'] = (bool) ( $waf_status['active'] ?? false );
        $redacted['extended_waf_server_type'] = sanitize_text_field( (string) ( $waf_status['server_type'] ?? '' ) );
        $redacted['bot_policy_matrix'] = RuleEngine::get_bot_policy_matrix();
        $redacted['robots_txt_static'] = \SiteFort\Modules\Firewall\AiTrainingRobots::static_robots_txt_present();

        return $redacted;
    }

    public function update_settings( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        if ( ! is_array( $params ) ) {
            return new WP_REST_Response( [ 'error' => 'Invalid JSON' ], 400 );
        }

        $current = FirewallSettings::get();
        $previous_settings = $current;
        $requested_allowlist = array_key_exists( 'allowlist', $params ) ? $params['allowlist'] : null;
        $patch_result = $this->apply_interactive_settings_patch( $current, $params, [ 'banned_ips', 'allowlist' ] );
        if ( $patch_result instanceof WP_REST_Response ) {
            return $patch_result;
        }
        $new_settings = $patch_result['settings'];

        if ( ! $this->save_interactive_firewall_settings(
            $new_settings,
            is_array( $patch_result['readiness'] ?? null ) ? $patch_result['readiness'] : null
        ) ) {
            return new \WP_Error(
                'sitefort_firewall_save_failed',
                'Failed to save firewall settings.',
                [ 'status' => 500 ]
            );
        }

        $cloudflare_sync = [ 'status' => 'skipped' ];
        $needs_cloudflare_sync = $this->did_cloudflare_relevant_firewall_settings_change( $previous_settings, $new_settings );
        $lockouts_payload = null;
        if ( $requested_allowlist !== null ) {
            $lockout_manager = new LockoutManager();
            $allowlist_result = $lockout_manager->replace_allowlist( $requested_allowlist );
            if ( ! $allowlist_result['success'] ) {
                if ( ! $this->save_interactive_firewall_settings( $previous_settings ) ) {
                    return new \WP_Error(
                        'sitefort_firewall_rollback_failed',
                        'Firewall settings rollback failed after the allowlist update error.',
                        [ 'status' => 500 ]
                    );
                }

                $code = sanitize_key( (string) ( $allowlist_result['code'] ?? 'sitefort_allowlist_update_failed' ) );
                $status = in_array( $code, [ 'overlap_conflict', 'settings_conflict' ], true ) ? 409 : 400;

                return new \WP_Error(
                    $code,
                    (string) $allowlist_result['message'],
                    [ 'status' => $status ]
                );
            }

            if ( ! empty( $allowlist_result['changed'] ) ) {
                $needs_cloudflare_sync = true;
                $lockouts_payload = [
                    'entries' => $lockout_manager->get_all(),
                    'counts'  => $lockout_manager->get_counts(),
                ];
            }
        }

        if ( $needs_cloudflare_sync ) {
            $cloudflare_sync = $this->maybe_reconcile_cloudflare_edge();
        }

        $blocklist_sync = $this->maybe_sync_blocklist_on_enable( $previous_settings, $new_settings );
        $response_settings = $this->with_computed_settings_flags(
            FirewallSettings::redact_sensitive_keys( $this->get_effective_firewall_settings() )
        );

        $response = [
            'message'        => $this->append_cloudflare_sync_message( 'Settings saved successfully.', $cloudflare_sync ),
            'settings'       => $response_settings,
            'blocklist_sync' => $blocklist_sync,
            'cloudflare_sync' => $cloudflare_sync,
        ];

        if ( is_array( $lockouts_payload ) ) {
            $response['lockouts'] = $lockouts_payload;
        }

        return new WP_REST_Response( $response );
    }

    public function get_detected_ip() {
        $settings        = $this->get_effective_firewall_settings();
        $resolution      = IpDetector::get_detection_details( $settings );
        $diagnostic      = IpDetector::detect_best_configuration();
        $remote_addr     = IpDetector::get_server_value( 'REMOTE_ADDR' );
        $trusted_proxies = IpDetector::resolve_trusted_proxies( $settings );
        $is_trusted      = ! empty( $trusted_proxies ) && IpUtils::ip_in_range_list( $remote_addr, $trusted_proxies );
        $selected_header = sanitize_text_field( (string) ( $settings['proxy_header'] ?? '' ) );
        $selected_entry  = null;

        if ( '' !== $selected_header ) {
            foreach ( $diagnostic['all_headers'] as $header_entry ) {
                if ( ! is_array( $header_entry ) ) {
                    continue;
                }

                if ( (string) ( $header_entry['header'] ?? '' ) !== $selected_header ) {
                    continue;
                }

                $selected_entry = $header_entry;
                break;
            }
        }

        if ( ! class_exists( 'SiteFort\\Includes\\IpProbe' ) ) {
            require_once dirname( __DIR__, 3 ) . '/includes/IpProbe.php';
        }
        $probe = IpProbe::get_recommendation();

        return new WP_REST_Response( [
            'ip'            => $resolution['ip'],
            'source'        => $resolution['source'],
            'source_key'    => $resolution['source_key'],
            'source_mode'   => $resolution['source_mode'],
            'restored_from' => $resolution['restored_from'],
            'origin_enforcement_mode' => FirewallConfigService::get_origin_enforcement_mode( $settings ),
            'remote_addr'   => $remote_addr,
            'trusted_proxy' => $is_trusted,
            'proxy_preset'  => $settings['trusted_proxy_preset'] ?? 'none',
            'trusted_count' => count( $trusted_proxies ),
            'readiness'     => IpDetector::get_readiness( $settings ),
            'probe'         => $probe ? [
                'status'      => $probe['status'],
                'header'      => $probe['header'],
                'origin_restored' => ! empty( $probe['origin_restored'] ),
                'detected_at' => $probe['detected_at'],
                'fresh'       => IpProbe::is_fresh(),
            ] : null,
            'review'        => [
                'selected_header'         => $selected_header,
                'selected_header_present' => ! empty( $selected_entry['present'] ),
                'selected_header_ip'      => sanitize_text_field( (string) ( $selected_entry['ip'] ?? '' ) ),
                'recommended_header'      => sanitize_text_field( (string) ( $diagnostic['recommended']['header'] ?? '' ) ),
                'recommended_ip'          => sanitize_text_field( (string) ( $diagnostic['recommended']['ip'] ?? '' ) ),
                'recommended_mode'        => sanitize_text_field( (string) ( $diagnostic['recommended_mode'] ?? '' ) ),
                'suggested_preset'        => sanitize_text_field( (string) ( $diagnostic['suggested_preset'] ?? 'none' ) ),
                'has_proxy'               => ! empty( $diagnostic['has_proxy'] ),
                'origin_restored'         => ! empty( $diagnostic['origin_restored'] ),
            ],
        ] );
    }

    /**
     * GET /firewall/ip/diagnose
     * Full diagnostic of all proxy headers and suggested configuration.
     */
    public function diagnose_ip() {
        return new WP_REST_Response( IpDetector::detect_best_configuration() );
    }

    /**
     * GET /firewall/ip-probe (public)
     * Receives the IntelEngine callback and stores the probe recommendation.
     */
    public function handle_ip_probe_callback( WP_REST_Request $request ): WP_REST_Response {
        if ( ! class_exists( 'SiteFort\\Includes\\IpProbe' ) ) {
            require_once dirname( __DIR__, 3 ) . '/includes/IpProbe.php';
        }
        $token  = sanitize_text_field( (string) $request->get_param( 'token' ) );
        $ie_ip  = sanitize_text_field( (string) $request->get_param( 'ip' ) );
        $result = IpProbe::handle_callback( $token, $ie_ip );

        if ( ! $result['ok'] ) {
            return new WP_REST_Response( $result, 400 );
        }

        // Clear the per-request IP cache and the readiness transient so the
        // next real admin page load recomputes both with the updated probe result.
        // Do NOT call cache_readiness() here: REMOTE_ADDR is IntelEngine's IP,
        // not a real visitor, so any cached readiness would be meaningless.
        IpDetector::reset_cache();
        delete_transient( IpDetector::READINESS_TRANSIENT );

        return new WP_REST_Response( $result, 200 );
    }

    /**
     * GET /firewall/ip-probe/status (admin)
     * Returns the current probe recommendation and freshness.
     */
    public function get_ip_probe_status(): WP_REST_Response {
        if ( ! class_exists( 'SiteFort\\Includes\\IpProbe' ) ) {
            require_once dirname( __DIR__, 3 ) . '/includes/IpProbe.php';
        }
        $probe = IpProbe::get_recommendation();
        return new WP_REST_Response( [
            'probe' => $probe ? [
                'status'      => $probe['status'],
                'header'      => $probe['header'],
                'origin_restored' => ! empty( $probe['origin_restored'] ),
                'detected_at' => $probe['detected_at'],
                'fresh'       => IpProbe::is_fresh(),
            ] : null,
        ] );
    }

    /**
     * POST /firewall/ip-probe/trigger (admin)
     * Initiates a fresh IntelEngine probe for this site.
     */
    public function trigger_ip_probe(): WP_REST_Response {
        if ( ! class_exists( 'SiteFort\\Includes\\IpProbe' ) ) {
            require_once dirname( __DIR__, 3 ) . '/includes/IpProbe.php';
        }
        $status = IpProbe::request_probe( true );
        $ok     = $status === 'dispatched';
        return new WP_REST_Response(
            [ 'ok' => $ok, 'message' => $ok ? 'probe_dispatched' : $status ],
            $ok ? 200 : 409
        );
    }

    /**
     * GET /firewall/proxy-presets
     * List available proxy provider presets.
     */
    public function get_proxy_presets() {
        return new WP_REST_Response( [
            'presets' => ProxyPresets::get_all(),
        ] );
    }

    // =========================================================================
    // GeoIP endpoints
    // =========================================================================

    /**
     * GET /firewall/geoip/settings
     * Returns GeoIP-related settings and database info.
     */
    public function get_geoip_settings() {
        $settings = $this->get_merged_settings();
        $downloader = new MaxMindDownloader();
        $db_info    = $downloader->get_db_info();
        $provider_status = FirewallSettings::get_geoip_provider_status();
        $db_info['usable'] = ! empty( $provider_status['local_provider_ready'] );

        $has_key = ! empty( $settings['geoip_license_key'] ) && ! empty( $settings['geoip_account_id'] );

        return new WP_REST_Response( [
            'geoip_enabled'        => (bool) ( $settings['geoip_enabled'] ?? false ) && ! empty( $provider_status['can_enable_country_blocking'] ),
            'geoip_mode'           => $settings['geoip_mode'] ?? 'block_list',
            'geoip_countries'      => $settings['geoip_countries'] ?? '',
            'geoip_key_configured' => $has_key,
            'geoip_key_hint'       => $has_key ? ( strlen( $settings['geoip_license_key'] ) > 4 ? substr( $settings['geoip_license_key'], -4 ) : '****' ) : '',
            'geoip_license_key'    => '',
            'geoip_account_id'     => $settings['geoip_account_id'] ?? '',
            'database'             => $db_info,
            'needs_update'         => $downloader->needs_update(),
            'local_provider_ready' => ! empty( $provider_status['local_provider_ready'] ),
            'cloudflare_edge_rule_ready' => ! empty( $provider_status['cloudflare_edge_rule_ready'] ),
            'cloudflare_origin_header_ready' => ! empty( $provider_status['cloudflare_origin_header_ready'] ),
            'origin_fallback_ready' => ! empty( $provider_status['origin_fallback_ready'] ),
            'cloudflare_detected' => ! empty( $provider_status['cloudflare_detected'] ),
            'server_restored_remote_addr' => ! empty( $provider_status['server_restored_remote_addr'] ),
            'origin_restored_diagnostic' => ! empty( $provider_status['origin_restored_diagnostic'] ),
            'provenance_failure_reason' => (string) ( $provider_status['provenance_failure_reason'] ?? '' ),
            'can_enable_country_blocking' => ! empty( $provider_status['can_enable_country_blocking'] ),
        ] );
    }

    /**
     * POST /firewall/geoip/settings
     * Update GeoIP-related settings.
     */
    public function update_geoip_settings( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        if ( ! is_array( $params ) ) {
            return new WP_REST_Response( [ 'error' => 'Invalid JSON' ], 400 );
        }

        // The Integrations page sends this flag so Save & Verify re-checks even
        // unchanged credentials; it is not a firewall setting, so pull it out
        // before patching.
        $force_verify = ! empty( $params['verify_credentials'] );
        unset( $params['verify_credentials'] );

        $previous_settings = FirewallSettings::get();
        $patch_result = $this->apply_interactive_settings_patch( $previous_settings, $params, [ 'banned_ips', 'allowlist' ] );
        if ( $patch_result instanceof WP_REST_Response ) {
            return $patch_result;
        }
        $current = $patch_result['settings'];

        // Verify MaxMind credentials against the MaxMind API before persisting
        // anything, so bad credentials are rejected instead of saved.
        $account_id  = trim( (string) ( $current['geoip_account_id'] ?? '' ) );
        $license_key = trim( (string) ( $current['geoip_license_key'] ?? '' ) );
        $credentials_changed = $account_id !== trim( (string) ( $previous_settings['geoip_account_id'] ?? '' ) )
            || $license_key !== trim( (string) ( $previous_settings['geoip_license_key'] ?? '' ) );
        $credentials_verified = false;

        if ( $force_verify && ( '' === $account_id || '' === $license_key ) ) {
            return new WP_REST_Response(
                [
                    'success' => false,
                    'code'    => 'sitefort_geoip_no_key',
                    'message' => 'MaxMind account ID and license key are both required.',
                ],
                400
            );
        }

        if ( ( $credentials_changed || $force_verify ) && '' !== $account_id && '' !== $license_key ) {
            $verify = ( new MaxMindDownloader() )->verify_credentials( $account_id, $license_key );
            if ( is_wp_error( $verify ) ) {
                $message = $verify->get_error_message();
                if ( 'sitefort_geoip_auth_failed' === $verify->get_error_code() ) {
                    $message .= ' Your settings have not been saved.';
                }
                return new WP_REST_Response(
                    [
                        'success' => false,
                        'code'    => $verify->get_error_code(),
                        'message' => $message,
                    ],
                    400
                );
            }
            $credentials_verified = true;
        }

        if ( ! $this->save_interactive_firewall_settings(
            $current,
            is_array( $patch_result['readiness'] ?? null ) ? $patch_result['readiness'] : null
        ) ) {
            return new WP_REST_Response(
                [
                    'success' => false,
                    'message' => 'Failed to save country blocking settings.',
                ],
                500
            );
        }
        $cloudflare_sync = $this->did_cloudflare_relevant_firewall_settings_change( $previous_settings, $current )
            ? $this->maybe_reconcile_cloudflare_edge()
            : [ 'status' => 'skipped' ];

        return new WP_REST_Response(
            [
                'success'              => true,
                'message'              => $this->append_cloudflare_sync_message( 'Country blocking settings saved successfully.', $cloudflare_sync ),
                'settings'             => FirewallSettings::redact_sensitive_keys( $current ),
                'cloudflare_sync'      => $cloudflare_sync,
                'credentials_verified' => $credentials_verified,
            ]
        );
    }

    /**
     * POST /firewall/geoip/update-db
     * Download/update the MaxMind GeoLite2-Country database using the stored
     * credentials. Credentials are only ever written through the verified
     * settings flow, never through this endpoint.
     */
    public function update_geoip_db() {
        $settings    = $this->get_merged_settings();
        $account_id  = trim( (string) ( $settings['geoip_account_id'] ?? '' ) );
        $license_key = trim( (string) ( $settings['geoip_license_key'] ?? '' ) );

        if ( '' === $account_id || '' === $license_key ) {
            return new WP_REST_Response( [ 'error' => 'MaxMind account ID and license key are both required.' ], 400 );
        }

        $downloader = new MaxMindDownloader();
        $result     = $downloader->download( $account_id, $license_key );

        if ( is_wp_error( $result ) ) {
            $status = 'sitefort_geoip_auth_failed' === $result->get_error_code() ? 400 : 502;
            return new WP_REST_Response( [
                'error' => $result->get_error_message(),
            ], $status );
        }

        $provider_status = FirewallSettings::get_geoip_provider_status();

        return new WP_REST_Response( [
            'success'  => true,
            'database' => array_merge(
                $downloader->get_db_info(),
                [ 'usable' => ! empty( $provider_status['local_provider_ready'] ) ]
            ),
        ] );
    }

    /**
     * DELETE /firewall/geoip/credentials
     * Remove the stored MaxMind credentials and the downloaded database.
     * Country blocking settings are kept so reconnecting restores behavior.
     */
    public function delete_geoip_credentials() {
        // persist_runtime_secrets() ignores blank keys by design (so generic
        // saves can't wipe the secret), so the encrypted key is cleared here.
        FirewallSettings::store_geoip_license_key( '' );

        $current = FirewallSettings::get();
        $current['geoip_account_id']  = '';
        $current['geoip_license_key'] = '';

        if ( ! $this->save_interactive_firewall_settings( $current ) ) {
            return new WP_REST_Response(
                [
                    'success' => false,
                    'message' => 'Failed to remove the MaxMind credentials.',
                ],
                500
            );
        }

        if ( ! ( new MaxMindDownloader() )->delete_database() ) {
            return new WP_REST_Response(
                [
                    'success' => true,
                    'message' => 'MaxMind credentials removed, but the downloaded database file could not be deleted.',
                ]
            );
        }

        return new WP_REST_Response( [
            'success' => true,
            'message' => 'MaxMind credentials and country database removed.',
        ] );
    }

    /**
     * GET /firewall/geoip/lookup?ip=x.x.x.x
     * Look up country information for an IP address.
     */
    public function geoip_lookup( WP_REST_Request $request ) {
        $ip      = trim( (string) $request->get_param( 'ip' ) );
        $service = new GeoIPService();
        $result  = $service->lookup_exact_ip( (string) $ip );

        if ( $result === null ) {
            return new WP_REST_Response( [
                'ip'    => $ip,
                'found' => false,
            ] );
        }

        return new WP_REST_Response( [
            'ip'           => $ip,
            'found'        => true,
            'country_code' => $result['country_code'],
            'country_name' => $result['country_name'],
        ] );
    }

    // =========================================================================
    // Blocklist endpoints
    // =========================================================================

    /**
     * GET /firewall/blocklist/settings
     * Returns blocklist-related settings.
     */
    public function get_blocklist_settings() {
        $settings = $this->get_merged_settings();

        return new WP_REST_Response( [
            'blocklist_enabled'              => (bool) ( $settings['blocklist_enabled'] ?? false ),
        ] );
    }

    /**
     * POST /firewall/blocklist/settings
     * Update blocklist-related settings.
     */
    public function update_blocklist_settings( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        if ( ! is_array( $params ) ) {
            return new WP_REST_Response( [ 'error' => 'Invalid JSON' ], 400 );
        }

        $current = FirewallSettings::get();
        $previous_settings = $current;
        $patch_result = $this->apply_interactive_settings_patch( $current, $params, [ 'banned_ips', 'allowlist' ] );
        if ( $patch_result instanceof WP_REST_Response ) {
            return $patch_result;
        }
        $current = $patch_result['settings'];

        if ( ! $this->save_interactive_firewall_settings(
            $current,
            is_array( $patch_result['readiness'] ?? null ) ? $patch_result['readiness'] : null
        ) ) {
            return new WP_REST_Response(
                [
                    'success' => false,
                    'message' => 'Failed to save community blocklist settings.',
                ],
                500
            );
        }
        $blocklist_sync = $this->maybe_sync_blocklist_on_enable( $previous_settings, $current );

        return new WP_REST_Response( [
            'success'        => true,
            'settings'       => FirewallSettings::redact_sensitive_keys( $current ),
            'blocklist_sync' => $blocklist_sync,
        ] );
    }

    /**
     * POST /firewall/blocklist/sync
     * Trigger an immediate blocklist sync.
     */
    public function sync_blocklist() {
        $settings = $this->get_merged_settings();
        $service  = new BlocklistService( $settings );
        $stats    = $service->sync();

        return new WP_REST_Response( [
            'success' => true,
            'stats'   => $stats,
        ] );
    }

    /**
     * GET /firewall/blocklist/stats
     * Get blocklist statistics.
     */
    public function get_blocklist_stats() {
        $settings = $this->get_merged_settings();
        $service  = new BlocklistService( $settings );
        $stats    = $service->get_stats();
        if ( ! isset( $stats['total_entries'] ) ) {
            $stats['total_entries'] = (int) ( $stats['entry_count'] ?? 0 );
        }

        return new WP_REST_Response( $stats );
    }

    /**
     * GET /firewall/extended-protection
     * Get the status of extended protection features.
     */
    public function get_extended_protection_status() {
        $waf_status = $this->serialize_extended_waf_status( $this->get_extended_waf_status() );

        // If the .user.ini deny rule is still missing on disk but the server-rule
        // writer can apply it, run a sync before returning status.
        if ( ! empty( $waf_status['user_ini_public_protection_required'] ) ) {
            $this->sync_server_rules_after_waf_change();
            $waf_status = $this->serialize_extended_waf_status( $this->get_extended_waf_status() );
        }

        return new WP_REST_Response( $waf_status );
    }

    /**
     * POST /firewall/extended-protection/user-ini-advisory/acknowledge
     * Hide the current .user.ini advisory for the current Nginx rule set when
     * this host cannot verify Nginx activation automatically.
     */
    public function acknowledge_extended_protection_user_ini_advisory() {
        $state = $this->get_server_rule_state_for_waf();
        $advisory = $this->build_user_ini_protection_advisory( $this->get_extended_waf_status(), $state );

        if ( empty( $advisory['acknowledgeable'] ) || empty( $advisory['sync_hash'] ) ) {
            return new WP_REST_Response( [
                'message' => 'This advisory cannot be acknowledged for the current configuration.',
            ], 400 );
        }

        SettingsStore::set_field(
            SettingsStore::COL_STATE,
            self::USER_INI_ADVISORY_ACK_KEY,
            [
                'sync_hash'       => (string) $advisory['sync_hash'],
                'acknowledged_at' => time(),
            ]
        );

        $waf_status = $this->serialize_extended_waf_status( $this->get_extended_waf_status() );

        return new WP_REST_Response( [
            'success' => true,
            'message' => 'Runtime advisory hidden until the Nginx rule set changes.',
            'status'  => $waf_status,
        ] );
    }

    /**
     * POST /firewall/extended-protection
     * Update WAF installation or extended firewall settings.
     */
    public function update_extended_protection( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        if ( ! is_array( $params ) ) {
            return new WP_REST_Response( [ 'error' => 'Invalid JSON' ], 400 );
        }

        $action = sanitize_key( (string) ( $params['action'] ?? '' ) );
        if ( $action === 'install' ) {
            if ( ! class_exists( '\SiteFort\Modules\Firewall\ExtendedProtection\Manager' ) ) {
                return new WP_REST_Response( [ 'error' => 'Extended protection manager is unavailable.' ], 500 );
            }

            $current_status = $this->serialize_extended_waf_status( ExtendedProtectionManager::get_status() );
            if ( $this->extended_protection_action_already_satisfied( 'install', $current_status ) ) {
                return new WP_REST_Response( [
                    'success' => true,
                    'message' => $this->build_extended_protection_message( $current_status, 'install' ),
                    'active'  => (bool) ( $current_status['active'] ?? false ),
                    'status'  => $current_status,
                ], ! empty( $current_status['pending_activation'] ) ? 202 : 200 );
            }

            $fs_error = AdminFilesystemCredentials::ensure_rest_access(
                $request,
                'extended_protection',
                [ ExtendedProtectionManager::class, 'can_install_without_credentials' ]
            );
            if ( $fs_error ) {
                return $fs_error;
            }

            $result = ExtendedProtectionManager::install();
            if ( empty( $result['success'] ) ) {
                $status = $this->serialize_extended_waf_status( ExtendedProtectionManager::get_status() );
                $message = sanitize_text_field( (string) ( $result['error'] ?? 'Failed to install extended protection.' ) );

                return new WP_REST_Response(
                    [
                        'success' => false,
                        'message' => $message,
                        'status'  => $status,
                    ],
                    500
                );
            }

            // Persist the server-rule managed block immediately so the .user.ini deny
            // rule is in place without waiting for the next admin_init drift sync.
            $this->sync_server_rules_after_waf_change();

            $status = $this->serialize_extended_waf_status( ExtendedProtectionManager::get_status() );
            if ( ! $this->extended_protection_install_reached_target_state( $status ) ) {
                return new WP_REST_Response(
                    [
                        'success' => false,
                        'message' => $this->build_extended_protection_target_failure_message( $status, 'install' ),
                        'method'  => sanitize_key( (string) ( $result['method'] ?? '' ) ),
                        'active'  => (bool) ( $status['active'] ?? false ),
                        'status'  => $status,
                    ],
                    500
                );
            }

            return new WP_REST_Response( [
                'success'  => true,
                'message'  => $this->build_extended_protection_message( $status, 'install' ),
                'method'   => sanitize_key( (string) ( $result['method'] ?? '' ) ),
                'active'   => (bool) ( $status['active'] ?? false ),
                'status'   => $status,
            ], ! empty( $status['pending_activation'] ) ? 202 : 200 );
        }

        if ( $action === 'uninstall' ) {
            if ( ! class_exists( '\SiteFort\Modules\Firewall\ExtendedProtection\Manager' ) ) {
                return new WP_REST_Response( [ 'error' => 'Extended protection manager is unavailable.' ], 500 );
            }

            $current_status = $this->serialize_extended_waf_status( ExtendedProtectionManager::get_status() );
            if ( $this->extended_protection_action_already_satisfied( 'uninstall', $current_status ) ) {
                $already_uninstalled = empty( $current_status['pending_deactivation'] )
                    && empty( $current_status['transition_operation'] )
                    && empty( $current_status['manual_deactivation_required'] );
                return new WP_REST_Response( [
                    'success'             => true,
                    'message'             => $this->build_extended_protection_message( $current_status, 'uninstall', $already_uninstalled ),
                    'active'              => (bool) ( $current_status['active'] ?? false ),
                    'already_uninstalled' => $already_uninstalled,
                    'status'              => $current_status,
                ], ! empty( $current_status['pending_deactivation'] ) ? 202 : 200 );
            }

            $fs_error = AdminFilesystemCredentials::ensure_rest_access(
                $request,
                'extended_protection',
                [ ExtendedProtectionManager::class, 'can_uninstall_without_credentials' ]
            );
            if ( $fs_error ) {
                return $fs_error;
            }

            $result = ExtendedProtectionManager::uninstall();
            if ( empty( $result['success'] ) ) {
                return new WP_REST_Response(
                    [
                        'message' => sanitize_text_field( (string) ( $result['error'] ?? 'Failed to remove extended protection.' ) ),
                        'status'  => $this->serialize_extended_waf_status( ExtendedProtectionManager::get_status() ),
                    ],
                    500
                );
            }

            // Refresh the managed block so the .user.ini deny rule is removed when no
            // longer needed (or kept in sync if other contributors still require it).
            $this->sync_server_rules_after_waf_change();

            $status = $this->serialize_extended_waf_status( ExtendedProtectionManager::get_status() );
            $already_uninstalled = ! empty( $result['already_uninstalled'] ) && empty( $status['manual_deactivation_required'] );
            if ( ! $this->extended_protection_uninstall_reached_target_state( $status ) ) {
                return new WP_REST_Response(
                    [
                        'success'             => false,
                        'message'             => $this->build_extended_protection_target_failure_message( $status, 'uninstall' ),
                        'active'              => (bool) ( $status['active'] ?? false ),
                        'already_uninstalled' => $already_uninstalled,
                        'status'              => $status,
                    ],
                    500
                );
            }

            $message = $this->build_extended_protection_message( $status, 'uninstall', $already_uninstalled );
            $previous_prepend_path = sanitize_text_field( (string) ( $result['previous_prepend_path'] ?? '' ) );
            if ( ! $already_uninstalled && $previous_prepend_path !== '' ) {
                $message .= ' Re-enable the preserved startup file manually if that integration is still required.';
            }

            return new WP_REST_Response( [
                'success'             => true,
                'message'             => $message,
                'active'              => (bool) ( $status['active'] ?? false ),
                'already_uninstalled' => $already_uninstalled,
                'status'              => $status,
            ], ! empty( $status['pending_deactivation'] ) ? 202 : 200 );
        }

        $current = FirewallSettings::get();
        $previous_settings = $current;
        $patch_result = $this->apply_interactive_settings_patch( $current, $params, [ 'banned_ips', 'allowlist' ] );
        if ( $patch_result instanceof WP_REST_Response ) {
            return $patch_result;
        }
        $current = $patch_result['settings'];

        if ( ! $this->save_interactive_firewall_settings(
            $current,
            is_array( $patch_result['readiness'] ?? null ) ? $patch_result['readiness'] : null
        ) ) {
            return new WP_REST_Response(
                [
                    'success' => false,
                    'message' => 'Failed to save extended protection settings.',
                ],
                500
            );
        }
        $blocklist_sync = $this->maybe_sync_blocklist_on_enable( $previous_settings, $current );
        $status = $this->get_extended_waf_status();

        return new WP_REST_Response( [
            'success'        => true,
            'settings'       => FirewallSettings::redact_sensitive_keys( $current ),
            'active'         => (bool) ( $status['active'] ?? false ),
            'status'         => $status,
            'blocklist_sync' => $blocklist_sync,
        ] );
    }

    // =========================================================================
    // IP Lockout management endpoints
    // =========================================================================

    /**
     * GET /firewall/lockouts
     * Returns all IP lockout entries (bans + allowlist) as structured data.
     */
    public function get_lockouts( WP_REST_Request $request ) {
        $type    = $request->get_param( 'type' );
        $manager = new LockoutManager();
        $entries = $manager->get_all( $type );
        $counts  = $manager->get_counts();

        return new WP_REST_Response( [
            'entries' => $entries,
            'counts'  => $counts,
        ] );
    }

    /**
     * POST /firewall/lockouts
     * Add an IP to the ban or allow list.
     */
    public function add_lockout( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        if ( ! is_array( $params ) ) {
            return new \WP_Error( 'sitefort_lockout_invalid_json', 'Invalid JSON.', [ 'status' => 400 ] );
        }

        $ip   = sanitize_text_field( trim( $params['ip'] ?? '' ) );
        $type = in_array( $params['type'] ?? '', [ 'ban', 'allow' ], true ) ? $params['type'] : 'ban';

        if ( '' === $ip ) {
            return new \WP_Error( 'sitefort_lockout_missing_ip', 'IP address is required.', [ 'status' => 400 ] );
        }

        $manager = new LockoutManager();
        $result  = $manager->add( $ip, $type, [
            'source'           => sanitize_key( $params['source'] ?? 'manual' ),
            'reason'           => sanitize_text_field( $params['reason'] ?? '' ),
            'duration'         => (int) ( $params['duration'] ?? 0 ),
            'replace_conflict' => ! empty( $params['replace_conflict'] ),
        ] );

        if ( ! $result['success'] ) {
            $code = sanitize_key( (string) ( $result['code'] ?? 'sitefort_lockout_error' ) );
            $status = in_array( $code, [ 'opposite_list_conflict', 'overlap_conflict', 'settings_conflict' ], true ) ? 409 : 400;
            return new \WP_Error( $code, (string) $result['message'], [ 'status' => $status ] );
        }
        $cloudflare_sync = $this->maybe_reconcile_cloudflare_edge();

        return new WP_REST_Response( [
            'success' => true,
            'message' => $this->append_cloudflare_sync_message( (string) $result['message'], $cloudflare_sync ),
            'entries' => $manager->get_all(),
            'counts'  => $manager->get_counts(),
            'cloudflare_sync' => $cloudflare_sync,
        ] );
    }

    /**
     * POST /firewall/lockouts/remove
     * Remove an IP from the ban or allow list.
     */
    public function remove_lockout( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        if ( ! is_array( $params ) ) {
            return new \WP_Error( 'sitefort_lockout_invalid_json', 'Invalid JSON.', [ 'status' => 400 ] );
        }

        $ip   = sanitize_text_field( trim( $params['ip'] ?? '' ) );
        $type = in_array( $params['type'] ?? '', [ 'ban', 'allow' ], true ) ? $params['type'] : 'ban';

        if ( '' === $ip ) {
            return new \WP_Error( 'sitefort_lockout_missing_ip', 'IP address is required.', [ 'status' => 400 ] );
        }

        $manager = new LockoutManager();
        $result  = $manager->remove( $ip, $type );

        if ( ! $result['success'] ) {
            return new \WP_Error( 'sitefort_lockout_remove_failed', (string) $result['message'], [ 'status' => 400 ] );
        }
        $cloudflare_sync = $this->maybe_reconcile_cloudflare_edge();

        return new WP_REST_Response( [
            'success' => true,
            'message' => $this->append_cloudflare_sync_message( (string) $result['message'], $cloudflare_sync ),
            'entries' => $manager->get_all(),
            'counts'  => $manager->get_counts(),
            'cloudflare_sync' => $cloudflare_sync,
        ] );
    }

    /**
     * POST /firewall/lockouts/purge-expired
     * Remove all expired bans.
     */
    public function purge_expired_lockouts() {
        $manager = new LockoutManager();
        $purged  = $manager->purge_expired();
        $cloudflare_sync = $purged > 0 ? $this->maybe_reconcile_cloudflare_edge() : [ 'status' => 'skipped' ];

        return new WP_REST_Response( [
            'success' => true,
            'purged'  => $purged,
            'message' => $purged > 0 ? $this->append_cloudflare_sync_message( sprintf( 'Purged %d expired entries.', $purged ), $cloudflare_sync ) : '',
            'entries' => $manager->get_all(),
            'counts'  => $manager->get_counts(),
            'cloudflare_sync' => $cloudflare_sync,
        ] );
    }

    // =========================================================================
    // Helpers
    // =========================================================================

    /**
     * Trigger an immediate sync when blocklist is newly enabled.
     *
     * @param array<string,mixed> $previous_settings
     * @param array<string,mixed> $new_settings
     * @return array{triggered: bool, success: bool, stats?: array<string,mixed>, error?: string}
     */
    private function maybe_sync_blocklist_on_enable( array $previous_settings, array $new_settings ): array {
        $was_enabled = ! empty( $previous_settings['blocklist_enabled'] );
        $is_enabled  = ! empty( $new_settings['blocklist_enabled'] );
        if ( $was_enabled || ! $is_enabled ) {
            return [
                'triggered' => false,
                'success'   => true,
            ];
        }

        try {
            $stats = ( new BlocklistService( $new_settings ) )->sync();

            return [
                'triggered' => true,
                'success'   => true,
                'stats'     => is_array( $stats ) ? $stats : [],
            ];
        } catch ( \Throwable $throwable ) {
            return [
                'triggered' => true,
                'success'   => false,
                'error'     => sanitize_text_field( $throwable->getMessage() ),
            ];
        }
    }

    /**
     * Reconcile Cloudflare edge state immediately for admin-driven rule changes.
     *
     * @return array{status:string,message?:string}
     */
    private function maybe_reconcile_cloudflare_edge(): array {
        if ( ! class_exists( '\SiteFort\Modules\Cloudflare\Manager' ) ) {
            return [ 'status' => 'skipped' ];
        }

        $manager  = new CloudflareManager();
        $settings = $manager->get_admin_settings();
        if ( empty( $settings['enabled'] ) || empty( $settings['configured'] ) ) {
            return [ 'status' => 'skipped' ];
        }

        $result = $manager->reconcile( true );
        if ( is_wp_error( $result ) ) {
            return [
                'status'  => 'failed',
                'message' => sanitize_text_field( $result->get_error_message() ),
            ];
        }

        if ( ! empty( $result['busy'] ) ) {
            return [
                'status'  => 'pending',
                'message' => sanitize_text_field( (string) ( $result['message'] ?? 'Cloudflare synchronization is already running.' ) ),
            ];
        }

        if ( ! empty( $result['pending'] ) ) {
            return [
                'status'  => 'pending',
                'message' => sanitize_text_field( (string) ( $result['message'] ?? 'Cloudflare update is still processing.' ) ),
            ];
        }

        if ( ! empty( $result['warning'] ) ) {
            return [
                'status'  => 'warning',
                'message' => sanitize_text_field( (string) ( $result['message'] ?? 'Cloudflare update completed with a warning.' ) ),
            ];
        }

        return [ 'status' => 'synced' ];
    }

    /**
     * @param array{status:string,message?:string} $cloudflare_sync
     */
    private function append_cloudflare_sync_message( string $message, array $cloudflare_sync ): string {
        $status = (string) ( $cloudflare_sync['status'] ?? 'skipped' );
        $sync_message = trim( (string) ( $cloudflare_sync['message'] ?? '' ) );

        if ( $status === 'failed' ) {
            return $message . ( $sync_message !== ''
                ? ' Local firewall updated, but Cloudflare sync failed: ' . $sync_message
                : ' Local firewall updated, but Cloudflare sync failed.'
            );
        }

        if ( $status === 'pending' ) {
            return $message . ( $sync_message !== ''
                ? ' Cloudflare update is still processing: ' . $sync_message
                : ' Cloudflare update is still processing.'
            );
        }

        if ( $status === 'warning' ) {
            return $message . ( $sync_message !== ''
                ? ' Cloudflare sync warning: ' . $sync_message
                : ' Cloudflare update completed with a warning.'
            );
        }

        return $message;
    }

    /**
     * @param array<string,mixed> $current
     * @param array<string,mixed> $params
     * @param array<int,string>   $locked_keys
     * @return array{settings: array<string,mixed>, readiness: ?array<string,mixed>}|WP_REST_Response
     */
    private function apply_interactive_settings_patch( array $current, array $params, array $locked_keys ) {
        try {
            return FirewallConfigService::apply_patch(
                $current,
                $params,
                $locked_keys,
                FirewallConfigService::CONTEXT_INTERACTIVE
            );
        } catch ( \InvalidArgumentException $exception ) {
            return new WP_REST_Response(
                [
                    'error' => sanitize_text_field( $exception->getMessage() ),
                ],
                400
            );
        }
    }

    /**
     * Persist firewall settings and immediately sync the effective
     * origin-enforcement verdict for non-interactive consumers.
     *
     * @param array<string,mixed>      $settings
     * @param array<string,mixed>|null $readiness
     */
    private function save_interactive_firewall_settings( array $settings, ?array $readiness = null ): bool {
        $settings = FirewallSettings::sanitize( $settings );

        if ( ! FirewallSettings::persist_runtime_secrets( $settings ) ) {
            return false;
        }

        if ( ! SettingsStore::save( SettingsStore::COL_FIREWALL, FirewallSettings::prepare_for_storage( $settings ) ) ) {
            return false;
        }

        if ( ! is_array( $readiness ) ) {
            $state = FirewallConfigService::reconcile_runtime_mode_from_sanitized( $settings );
            $settings = $state['settings'];
            $readiness = is_array( $state['readiness'] ?? null ) ? $state['readiness'] : null;
        }

        FirewallConfigService::sync_effective_mode_state(
            $settings,
            FirewallConfigService::get_origin_enforcement_mode( $settings ),
            $readiness
        );

        return true;
    }

    /**
     * @return array<string,mixed>
     */
    private function get_extended_waf_status(): array {
        if ( ! class_exists( '\SiteFort\Modules\Firewall\ExtendedProtection\Manager' ) ) {
            return [
                'active'      => false,
                'server_type' => '',
            ];
        }

        $status = ExtendedProtectionManager::get_status();
        if ( ! is_array( $status ) ) {
            return [
                'active'      => false,
                'server_type' => '',
            ];
        }

        return $status;
    }

    /**
     * Whether a requested WAF target is already the durable desired state or
     * already converging toward it. This check intentionally runs before
     * filesystem credential challenges so cached host propagation does not
     * block a same-target user action.
     *
     * @param 'install'|'uninstall' $action
     * @param array<string,mixed>   $status
     */
    private function extended_protection_action_already_satisfied( string $action, array $status ): bool {
        if ( $action === 'install' ) {
            if ( ! empty( $status['activation_failed'] ) ) {
                return false;
            }

            if ( ! empty( $status['fallback_cleanup_required'] ) ) {
                return false;
            }

            return ! empty( $status['desired_active'] )
                && (
                    ! empty( $status['active'] )
                    || ! empty( $status['pending_activation'] )
                    || (string) ( $status['transition_operation'] ?? '' ) === 'install'
                    || ! empty( $status['manual_configuration_required'] )
                );
        }

        if ( ! empty( $status['pending_deactivation'] ) || (string) ( $status['transition_operation'] ?? '' ) === 'uninstall' ) {
            return empty( $status['pending_deactivation_cleanup_required'] );
        }

        return empty( $status['active'] )
            && empty( $status['desired_active'] )
            && empty( $status['installed_on_disk'] )
            && empty( $status['pending_activation'] )
            && empty( $status['manual_configuration_required'] );
    }

    /**
     * @param array<string,mixed> $status
     */
    private function extended_protection_install_reached_target_state( array $status ): bool {
        if ( ! empty( $status['activation_failed'] ) ) {
            return false;
        }

        if ( ! empty( $status['fallback_cleanup_required'] ) ) {
            return false;
        }

        if ( array_key_exists( 'desired_active', $status ) && ! (bool) $status['desired_active'] ) {
            return false;
        }

        return ! empty( $status['active'] )
            || ! empty( $status['pending_activation'] )
            || ! empty( $status['manual_configuration_required'] );
    }

    /**
     * @param array<string,mixed> $status
     */
    private function extended_protection_uninstall_reached_target_state( array $status ): bool {
        if ( ! empty( $status['pending_deactivation'] ) ) {
            return empty( $status['pending_deactivation_cleanup_required'] );
        }

        return empty( $status['active'] )
            && empty( $status['desired_active'] )
            && empty( $status['installed_on_disk'] )
            && empty( $status['pending_activation'] )
            && empty( $status['manual_configuration_required'] );
    }

    /**
     * @param array<string,mixed> $status
     * @param 'install'|'uninstall' $action
     */
    private function build_extended_protection_target_failure_message( array $status, string $action ): string {
        if ( $action === 'install' ) {
            if ( ! empty( $status['activation_failed'] ) ) {
                $message = isset( $status['activation_failure_message'] ) ? trim( (string) $status['activation_failure_message'] ) : '';
                return $message !== '' ? $message : $this->build_extended_protection_confirmation_failure_message( $status );
            }

            if ( ! empty( $status['fallback_cleanup_required'] ) ) {
                $message = isset( $status['fallback_cleanup_message'] ) ? trim( (string) $status['fallback_cleanup_message'] ) : '';
                return $message !== '' ? $message : 'Server-Level WAF activation was confirmed, but the Early Loader fallback could not be removed.';
            }

            if ( array_key_exists( 'desired_active', $status ) && ! (bool) $status['desired_active'] ) {
                return 'Server-Level WAF files are installed, but SiteFort could not save the enabled state.';
            }

            return $this->build_extended_protection_confirmation_failure_message( $status );
        }

        if ( ! empty( $status['pending_deactivation_cleanup_required'] ) ) {
            $message = isset( $status['pending_deactivation_message'] ) ? trim( (string) $status['pending_deactivation_message'] ) : '';
            return $message !== '' ? $message : 'Server-Level WAF removal could not finish because SiteFort startup files remain on this server.';
        }

        return 'Server-Level WAF removal was requested, but your server still reports active server-level enforcement.';
    }

    private function build_extended_protection_confirmation_failure_message( array $status ): string {
        $server_type = sanitize_key( (string) ( $status['server_type'] ?? '' ) );

        if ( in_array( $server_type, [ 'apache_mod', 'apache_cgi' ], true ) ) {
            return 'Server-Level WAF files are installed, but Apache did not confirm PHP startup enforcement. Review the auto_prepend_file rule, firewall origin enforcement, and WordPress loopback access.';
        }

        if ( 'litespeed' === $server_type ) {
            return 'Server-Level WAF files are installed, but LiteSpeed did not confirm PHP startup enforcement. Review LiteSpeed PHP configuration, firewall origin enforcement, and WordPress loopback access.';
        }

        if ( 'nginx_fpm' === $server_type ) {
            return 'Server-Level WAF files are installed, but PHP-FPM did not confirm startup enforcement. Review .user.ini or auto_prepend_file configuration, firewall origin enforcement, and WordPress loopback access.';
        }

        return 'Server-Level WAF files are installed, but runtime enforcement could not be confirmed. Review PHP auto_prepend_file configuration, firewall origin enforcement, and WordPress loopback access.';
    }

    /**
     * Load firewall settings with effective origin mode reconciled for the
     * current request context.
     *
     * This keeps settings reads aligned with the same readiness verdict used
     * by runtime enforcement instead of trusting a potentially stale saved mode.
     *
     * @return array<string,mixed>
     */
    private function get_effective_firewall_settings(): array {
        return FirewallConfigService::load_effective_settings();
    }

    /**
     * Run a synchronous ServerConfigWriter sync after a WAF install/uninstall so the
     * .user.ini deny rule (contributed via the sitefort_server_rules_nginx filter) is
     * persisted to the managed include file immediately. Failures are tolerated: the
     * admin_init drift sync remains as a backstop and the WAF status will still report
     * user_ini_public_protection_required when the writer cannot apply the rule.
     */
    private function sync_server_rules_after_waf_change(): void {
        if ( ! class_exists( '\\SiteFort\\Includes\\ServerConfigWriter' ) ) {
            return;
        }

        $state = \SiteFort\Includes\ServerConfigWriter::get_state();
        if ( empty( $state['write_enabled'] ) || empty( $state['writable'] ) ) {
            return;
        }

        if ( \SiteFort\Includes\ServerConfigWriter::write() ) {
            \SiteFort\Includes\ServerConfigWriter::clear_sync_failure();
        }
    }

    /**
     * @param array<string, mixed> $waf_status
     * @return array<string, mixed>
     */
    private function serialize_extended_waf_status( array $waf_status ): array {
        $server_rule_state = $this->get_server_rule_state_for_waf();
        $user_ini_advisory = $this->build_user_ini_protection_advisory( $waf_status, $server_rule_state );

        return [
            'active'             => ! empty( $waf_status['active'] ),
            'desired_active'     => ! empty( $waf_status['desired_active'] ),
            'active_layer'       => sanitize_key( (string) ( $waf_status['active_layer'] ?? '' ) ),
            'transition_operation' => sanitize_key( (string) ( $waf_status['transition_operation'] ?? '' ) ),
            'fallback_context'   => sanitize_key( (string) ( $waf_status['fallback_context'] ?? '' ) ),
            'fallback_message'   => isset( $waf_status['fallback_message'] ) ? sanitize_text_field( (string) $waf_status['fallback_message'] ) : null,
            'cooldown_active'    => ! empty( $waf_status['cooldown_active'] ),
            'cooldown_message'   => isset( $waf_status['cooldown_message'] ) ? sanitize_text_field( (string) $waf_status['cooldown_message'] ) : '',
            'installed_on_disk'  => ! empty( $waf_status['installed_on_disk'] ),
            'can_uninstall'      => ! empty( $waf_status['installed_on_disk'] )
                || ! empty( $waf_status['pending_activation'] )
                || ! empty( $waf_status['pending_deactivation'] )
                || ! empty( $waf_status['manual_configuration_required'] )
                || ! empty( $waf_status['activation_failed'] )
                || ! empty( $waf_status['bootstrap_exists'] )
                || ( ! empty( $waf_status['active'] ) && (string) ( $waf_status['active_layer'] ?? '' ) !== '' ),
            'pending_activation' => ! empty( $waf_status['pending_activation'] ),
            'pending_message'    => isset( $waf_status['pending_message'] ) ? sanitize_text_field( (string) $waf_status['pending_message'] ) : null,
            'activation_failed'  => ! empty( $waf_status['activation_failed'] ),
            'activation_failure_message' => isset( $waf_status['activation_failure_message'] ) ? sanitize_text_field( (string) $waf_status['activation_failure_message'] ) : null,
            'pending_deactivation' => ! empty( $waf_status['pending_deactivation'] ),
            'pending_deactivation_message' => isset( $waf_status['pending_deactivation_message'] ) ? sanitize_text_field( (string) $waf_status['pending_deactivation_message'] ) : null,
            'pending_deactivation_cache_wait' => ! empty( $waf_status['pending_deactivation_cache_wait'] ),
            'pending_deactivation_cleanup_required' => ! empty( $waf_status['pending_deactivation_cleanup_required'] ),
            'manual_deactivation_required' => ! empty( $waf_status['manual_deactivation_required'] ),
            'manual_deactivation_message' => isset( $waf_status['manual_deactivation_message'] ) ? sanitize_text_field( (string) $waf_status['manual_deactivation_message'] ) : null,
            'manual_configuration_required' => ! empty( $waf_status['manual_configuration_required'] ),
            'manual_configuration_message' => isset( $waf_status['manual_configuration_message'] ) ? sanitize_text_field( (string) $waf_status['manual_configuration_message'] ) : null,
            'server_type' => sanitize_key( (string) ( $waf_status['server_type'] ?? '' ) ),
            'fallback_cleanup_required' => ! empty( $waf_status['fallback_cleanup_required'] ),
            'fallback_cleanup_message' => isset( $waf_status['fallback_cleanup_message'] ) ? sanitize_text_field( (string) $waf_status['fallback_cleanup_message'] ) : null,
            'user_ini_public_protection_required' => ! empty( $user_ini_advisory['required'] ),
            'user_ini_public_protection_title' => sanitize_text_field( (string) ( $user_ini_advisory['title'] ?? '' ) ),
            'user_ini_public_protection_message' => isset( $user_ini_advisory['message'] ) ? sanitize_text_field( (string) $user_ini_advisory['message'] ) : null,
            'user_ini_public_protection_action' => sanitize_key( (string) ( $user_ini_advisory['action'] ?? '' ) ),
            'user_ini_public_protection_action_label' => sanitize_text_field( (string) ( $user_ini_advisory['action_label'] ?? '' ) ),
            'user_ini_public_protection_secondary_action' => sanitize_key( (string) ( $user_ini_advisory['secondary_action'] ?? '' ) ),
            'user_ini_public_protection_secondary_action_label' => sanitize_text_field( (string) ( $user_ini_advisory['secondary_action_label'] ?? '' ) ),
            'user_ini_public_protection_tertiary_action' => sanitize_key( (string) ( $user_ini_advisory['tertiary_action'] ?? '' ) ),
            'user_ini_public_protection_tertiary_action_label' => sanitize_text_field( (string) ( $user_ini_advisory['tertiary_action_label'] ?? '' ) ),
            'user_ini_public_protection_acknowledgeable' => ! empty( $user_ini_advisory['acknowledgeable'] ),
            'chained_prepend_preserved' => trim( (string) ( $waf_status['chained_prepend_path'] ?? '' ) ) !== '',
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function get_server_rule_state_for_waf(): array {
        if ( ! class_exists( '\\SiteFort\\Includes\\ServerConfigWriter' ) ) {
            return [];
        }

        $state = \SiteFort\Includes\ServerConfigWriter::get_state();
        return is_array( $state ) ? $state : [];
    }

    /**
     * @param array<string,mixed> $waf_status
     * @param array<string,mixed> $server_rule_state
     * @return array<string,mixed>
     */
    private function build_user_ini_protection_advisory( array $waf_status, array $server_rule_state ): array {
        $base_required = ! empty( $waf_status['user_ini_public_protection_required'] );
        $server_type   = (string) ( $waf_status['server_type'] ?? '' );
        $bootstrap_exists = ! empty( $waf_status['bootstrap_exists'] );
        $server_rule_banner = isset( $server_rule_state['banner'] ) && is_array( $server_rule_state['banner'] )
            ? $server_rule_state['banner']
            : [];
        $server_rule_verification = isset( $server_rule_state['verification'] ) && is_array( $server_rule_state['verification'] )
            ? $server_rule_state['verification']
            : [];
        $server_rule_sync_hash = is_string( $server_rule_state['sync_hash'] ?? null ) ? (string) $server_rule_state['sync_hash'] : '';
        $verification_status = is_string( $server_rule_verification['status'] ?? null ) ? (string) $server_rule_verification['status'] : '';
        $server_rule_requires_attention = false;

        if ( $server_type === 'nginx_fpm' && $bootstrap_exists ) {
            $server_rule_requires_attention = ! empty( $server_rule_banner['show'] );
        }

        // Dismissible whenever the banner is showing for a server-rule reason. The dismissal is
        // bound to the current sync_hash, so the banner reappears automatically if the
        // underlying rules or verification state change.
        $acknowledgeable = $server_rule_requires_attention && $server_rule_sync_hash !== '';
        $acknowledged = $this->is_user_ini_advisory_acknowledged( $server_rule_sync_hash );
        $required = ( $base_required || $server_rule_requires_attention ) && ! ( $acknowledgeable && $acknowledged );

        $title = 'Action needed to protect the WAF config file';
        $message = isset( $waf_status['user_ini_public_protection_message'] ) ? trim( (string) $waf_status['user_ini_public_protection_message'] ) : '';
        $action = '';
        $action_label = '';
        $secondary_action = '';
        $secondary_action_label = '';
        $tertiary_action = '';
        $tertiary_action_label = '';

        if ( $server_rule_requires_attention ) {
            $message = $this->build_user_ini_protection_message_from_server_rule( $verification_status, $message );
            $action = is_string( $server_rule_banner['action'] ?? null ) ? (string) $server_rule_banner['action'] : '';
            $action_label = is_string( $server_rule_banner['action_label'] ?? null ) ? (string) $server_rule_banner['action_label'] : '';
            $secondary_action = is_string( $server_rule_banner['secondary_action'] ?? null ) ? (string) $server_rule_banner['secondary_action'] : '';
            $secondary_action_label = is_string( $server_rule_banner['secondary_action_label'] ?? null ) ? (string) $server_rule_banner['secondary_action_label'] : '';
            $tertiary_action = is_string( $server_rule_banner['tertiary_action'] ?? null ) ? (string) $server_rule_banner['tertiary_action'] : '';
            $tertiary_action_label = is_string( $server_rule_banner['tertiary_action_label'] ?? null ) ? (string) $server_rule_banner['tertiary_action_label'] : '';
        }

        if ( $required ) {
            [
                'action' => $action,
                'action_label' => $action_label,
                'secondary_action' => $secondary_action,
                'secondary_action_label' => $secondary_action_label,
                'tertiary_action' => $tertiary_action,
                'tertiary_action_label' => $tertiary_action_label,
            ] = $this->normalize_user_ini_protection_actions(
                $verification_status
            );
        }

        if ( $message === '' ) {
            $message = 'Make sure your Nginx server block includes the generated rules that block public access to the WAF config file (.user.ini).';
        }

        return [
            'required'               => $required,
            'title'                  => $title,
            'message'                => $message,
            'action'                 => $action,
            'action_label'           => $action_label,
            'secondary_action'       => $secondary_action,
            'secondary_action_label' => $secondary_action_label,
            'tertiary_action'        => $tertiary_action,
            'tertiary_action_label'  => $tertiary_action_label,
            'acknowledgeable'        => $acknowledgeable,
            'sync_hash'              => $server_rule_sync_hash,
        ];
    }

    private function build_user_ini_protection_message_from_server_rule( string $verification_status, string $fallback_message ): string {
        switch ( $verification_status ) {
            case 'failed':
            case 'pending':
            case 'unavailable':
                return 'SiteFort added rules to block public access to the WAF config file (.user.ini), but Nginx is not loading them yet. Include the generated rules in your active Nginx server block, then verify.';
            case 'unsupported':
                return 'This host does not support automatic verification. Confirm manually that your Nginx server block includes the generated rules that block public access to the WAF config file (.user.ini).';
            default:
                return $fallback_message !== ''
                    ? $fallback_message
                    : 'Make sure your Nginx server block includes the generated rules that block public access to the WAF config file (.user.ini).';
        }
    }

    /**
     * Ensure the WAF advisory always carries stable backend-driven actions.
     *
     * @return array{action:string,action_label:string,secondary_action:string,secondary_action_label:string,tertiary_action:string,tertiary_action_label:string}
     */
    private function normalize_user_ini_protection_actions( string $verification_status ): array {
        $action = 'open_manual_rules';
        $action_label = 'Open Manual Rules';
        $secondary_action = 'open_advanced_server_settings';
        $secondary_action_label = 'Change Config Path';
        $tertiary_action = '';
        $tertiary_action_label = '';

        if ( in_array( $verification_status, [ 'failed', 'pending', 'unavailable' ], true ) ) {
            $secondary_action = 'verify';
            $secondary_action_label = 'Verify Activation';
            $tertiary_action = 'open_advanced_server_settings';
            $tertiary_action_label = 'Change Config Path';
        }

        return [
            'action' => $action,
            'action_label' => $action_label,
            'secondary_action' => $secondary_action,
            'secondary_action_label' => $secondary_action_label,
            'tertiary_action' => $tertiary_action,
            'tertiary_action_label' => $tertiary_action_label,
        ];
    }

    private function is_user_ini_advisory_acknowledged( string $sync_hash ): bool {
        if ( $sync_hash === '' ) {
            return false;
        }

        $state = SettingsStore::get_field( SettingsStore::COL_STATE, self::USER_INI_ADVISORY_ACK_KEY, [] );
        if ( ! is_array( $state ) ) {
            return false;
        }

        return is_string( $state['sync_hash'] ?? null ) && hash_equals( $sync_hash, (string) $state['sync_hash'] );
    }

    /**
     * Build the canonical user-facing message for WAF install/uninstall transitions.
     *
     * @param array<string, mixed> $status
     * @param 'install'|'uninstall' $action
     */
    private function build_extended_protection_message( array $status, string $action, bool $already_uninstalled = false ): string {
        if ( $action === 'install' ) {
            if ( ! empty( $status['manual_configuration_required'] ) ) {
                $message = isset( $status['manual_configuration_message'] ) ? trim( (string) $status['manual_configuration_message'] ) : '';
                return $message !== '' ? $message : 'Server-level WAF is installed. Manual PHP configuration is required to finish activation.';
            }

            if ( ! empty( $status['pending_activation'] ) ) {
                $pending_message = isset( $status['pending_message'] ) ? trim( (string) $status['pending_message'] ) : '';
                return $pending_message !== '' ? $pending_message : 'Server-level WAF installed. Activation is pending.';
            }

            if ( ! empty( $status['activation_failed'] ) ) {
                $message = isset( $status['activation_failure_message'] ) ? trim( (string) $status['activation_failure_message'] ) : '';
                return $message !== '' ? $message : $this->build_extended_protection_confirmation_failure_message( $status );
            }

            if ( ! empty( $status['fallback_cleanup_required'] ) ) {
                $message = isset( $status['fallback_cleanup_message'] ) ? trim( (string) $status['fallback_cleanup_message'] ) : '';
                return $message !== '' ? $message : 'Server-level WAF is active, but the Early Loader fallback could not be removed yet.';
            }

            if ( ! empty( $status['active'] ) ) {
                if ( ! empty( $status['chained_prepend_preserved'] ) ) {
                    return 'Server-level WAF installed and active. The existing startup file is preserved in the SiteFort bootstrap.';
                }

                return 'Server-level WAF installed and active.';
            }

            if ( ! empty( $status['installed_on_disk'] ) ) {
                if ( ! empty( $status['chained_prepend_preserved'] ) ) {
                    return 'Server-level WAF installed. The existing startup file is preserved while runtime activation is verified.';
                }

                return 'Server-level WAF installed. Runtime activation is still being verified.';
            }

            return 'Server-level WAF install completed.';
        }

        if ( ! empty( $status['manual_deactivation_required'] ) ) {
            $message = isset( $status['manual_deactivation_message'] ) ? trim( (string) $status['manual_deactivation_message'] ) : '';
            return $message !== '' ? $message : 'Server-level WAF enforcement is disabled, but the host still has a manual PHP startup reference to the SiteFort bootstrap.';
        }

        if ( $already_uninstalled ) {
            return 'Server-level WAF was already off. Nothing to remove.';
        }

        if ( ! empty( $status['pending_deactivation'] ) ) {
            $pending_message = isset( $status['pending_deactivation_message'] ) ? trim( (string) $status['pending_deactivation_message'] ) : '';
            return $pending_message !== '' ? $pending_message : 'The server-level Web Application Firewall (WAF) has been disabled. The server is now applying the update.';
        }

        return 'Server-level WAF removed.';
    }

    /**
     * Cloudflare mirrors only a subset of local firewall settings directly.
     *
     * @param array<string,mixed> $settings
     * @return array<string,mixed>
     */
    private function get_cloudflare_relevant_firewall_state( array $settings ): array {
        $settings = FirewallSettings::sanitize( $settings );

        return [
            'geoip_enabled'        => ! empty( $settings['geoip_enabled'] ),
            'geoip_mode'           => (string) ( $settings['geoip_mode'] ?? 'block_list' ),
            'geoip_countries'      => (string) ( $settings['geoip_countries'] ?? '' ),
            'banned_user_agents'   => (string) ( $settings['banned_user_agents'] ?? '' ),
            'trusted_user_agents'  => (string) ( $settings['trusted_user_agents'] ?? '' ),
        ];
    }

    /**
     * Determine whether a local firewall edit requires Cloudflare edge re-sync.
     *
     * @param array<string,mixed> $previous_settings
     * @param array<string,mixed> $current_settings
     */
    private function did_cloudflare_relevant_firewall_settings_change( array $previous_settings, array $current_settings ): bool {
        return $this->get_cloudflare_relevant_firewall_state( $previous_settings ) !== $this->get_cloudflare_relevant_firewall_state( $current_settings );
    }

    /**
     * Stored firewall settings merged with defaults and runtime secrets.
     * Callers must pass the result through redact_sensitive_keys() before
     * returning it to the client.
     */
    private function get_merged_settings(): array {
        return FirewallSettings::get();
    }

}
