<?php

declare(strict_types=1);

namespace ShieldX\Core;

defined('ABSPATH') || exit;

use ShieldX\Admin\AdminPageLoader;
use ShieldX\Admin\VirtualPatchAdmin;
use ShieldX\Api\StatusController;
use ShieldX\Database\SchemaInstaller;
use ShieldX\Licensing\LicenseManager;
use ShieldX\Licensing\LicenseSettings;
use ShieldX\Maintenance\AdminChangeShadowScanner;
use ShieldX\Maintenance\BackupStorageManager;
use ShieldX\Maintenance\DatabaseBackupManager;
use ShieldX\Maintenance\DatabaseTableReporter;
use ShieldX\Maintenance\FileShadowCopyManager;
use ShieldX\Maintenance\LogRotator;
use ShieldX\Maintenance\RestorePointManager;
use ShieldX\Maintenance\ScheduledRestorePointRunner;
use ShieldX\Maintenance\ScheduledRestorePointSettings;
use ShieldX\Maintenance\ScheduledBackupRunner;
use ShieldX\Maintenance\ScheduledBackupSettings;
use ShieldX\Privacy\PrivacyManager;
use ShieldX\Security\AuditLogger;
use ShieldX\Security\EmergencyRecoveryThrottle;
use ShieldX\Security\EmergencyRecoveryThrottleStorageUnavailableException;
use ShieldX\Security\Hardening;
use ShieldX\Security\HardeningSettings;
use ShieldX\Security\HumanChallenge;
use ShieldX\Security\HumanChallengeSettings;
use ShieldX\Security\Lockdown;
use ShieldX\Security\LockdownSettings;
use ShieldX\Security\LoginSecurity;
use ShieldX\Security\MinimalWaf;
use ShieldX\Security\VirtualPatchRepository;
use ShieldX\Security\RateLimiter;
use ShieldX\Security\RateLimitSettings;
use ShieldX\Security\ReputationFeed;
use ShieldX\Security\TwoFactor;
use ShieldX\Security\UserProfiler;
use ShieldX\Security\WafSettings;
use ShieldX\Security\WafStats;
use ShieldX\Support\WordPressPaths;
use ShieldX\Scanner\LocalScanner;
use ShieldX\Scanner\ScheduledScanRunner;
use ShieldX\Scanner\ScheduledScanSettings;
use ShieldX\Scanner\VulnerabilityScanner;
use ShieldX\Storage\FileStorage;
use ShieldX\Support\ExternalRunnerToken;
use ShieldX\Support\RequestPath;
use ShieldX\WPCLI\DatabaseCommand;
use ShieldX\WPCLI\LockdownCommand;
use ShieldX\WPCLI\LogCommand;
use ShieldX\WPCLI\ScanCommand;
use ShieldX\WPCLI\StatusCommand;
use ShieldX\WPCLI\WafCommand;

final class Plugin
{
    private const AUTOSCAN_THROTTLE_SECONDS = 60;
    private const STATUS_CACHE_TTL_SECONDS = 5;
    private const DATABASE_TABLE_SNAPSHOT_OPTION = 'shieldx_audit_known_tables';
    private const DATABASE_TABLES_CACHE_TRANSIENT = 'shieldx_audit_database_tables_cache';
    private const DATABASE_TABLES_CACHE_VERSION_OPTION = 'shieldx_audit_database_tables_cache_version';
    private const DATABASE_TABLES_CACHE_TTL_SECONDS = 3600;
    private const MAINTENANCE_LAST_RESULT_OPTION = 'shieldx_maintenance_last_result';
    private const SCHEDULER_RECONCILE_TRANSIENT = 'shieldx_core_scheduler_reconcile';
    private const SCHEDULER_RECONCILE_TTL_SECONDS = 3600;
    private const PLUGIN_FILESYSTEM_NOTICE_CACHE_TTL_SECONDS = 300;

    /** @var self|null */
    private static $instance;

    /** @var bool */
    private static $databaseTablesCacheInvalidationHooksRegistered = false;

    /** @var FileStorage|null */
    private $storage;

    /** @var EnvironmentDetector|null */
    private $environment;

    /** @var MinimalWaf|null */
    private $waf;

    /** @var WafSettings|null */
    private $wafSettings;

    /** @var HardeningSettings|null */
    private $hardeningSettings;

    /** @var LockdownSettings|null */
    private $lockdownSettings;

    /** @var Lockdown|null */
    private $lockdown;

    /** @var RateLimitSettings|null */
    private $rateLimitSettings;

    /** @var RateLimiter|null */
    private $rateLimiter;

    /** @var AuditLogger|null */
    private $logger;

    /** @var WafStats|null */
    private $wafStats;

    /** @var LocalScanner|null */
    private $scanner;

    /** @var ScheduledScanSettings|null */
    private $scanSettings;

    /** @var ScheduledScanRunner|null */
    private $scheduledScanRunner;

    /** @var ScheduledBackupSettings|null */
    private $backupSettings;

    /** @var ScheduledBackupRunner|null */
    private $scheduledBackupRunner;

    /** @var ScheduledRestorePointSettings|null */
    private $restorePointScheduleSettings;

    /** @var ScheduledRestorePointRunner|null */
    private $scheduledRestorePointRunner;

    /** @var LoginSecurity|null */
    private $loginSecurity;

    /** @var HumanChallenge|null */
    private $humanChallenge;

    /** @var array<string,int> */
    private $loginFailureDedupe = [];

    /** @var EmergencyRecoveryThrottle|null */
    private $emergencyRecoveryThrottle;

    /** @var UserProfiler|null */
    private $userProfiler;

    /** @var array<string,mixed>|null */
    private $statusCache;

    /** @var float */
    private $statusCacheCreatedAt = 0.0;

    /** @var array<string,array<string,mixed>> */
    private $adminStatusCache = [];

    /** @var array<int,string>|null */
    private $databaseTablesCache;

    /** @var UpdateCoordinator|null */
    private $updateCoordinator;

    /** @var LicenseManager|null */
    private $licenseManager;

    /** @var bool */
    private $adminAuditHooksRegistered = false;

    private function __construct()
    {
        RequestCache::registerOptionInvalidationHooks();
        self::registerDatabaseTablesCacheInvalidationHooks();
        $this->scanner = null;
        $this->wafStats = null;
        $this->scanSettings = null;
        $this->scheduledScanRunner = null;
        $this->backupSettings = null;
        $this->scheduledBackupRunner = null;
        $this->restorePointScheduleSettings = null;
        $this->scheduledRestorePointRunner = null;
        $this->userProfiler = null;
        $this->updateCoordinator = null;
        $this->licenseManager = null;
        $this->databaseTablesCache = null;
    }

    private static function registerDatabaseTablesCacheInvalidationHooks(): void
    {
        if (self::$databaseTablesCacheInvalidationHooksRegistered || !function_exists('add_action')) {
            return;
        }

        add_action('updated_option', [self::class, 'invalidateDatabaseTablesCacheForOptionChange'], 10, 3);
        add_action('added_option', [self::class, 'invalidateDatabaseTablesCacheForOptionChange'], 10, 2);
        add_action('deleted_option', [self::class, 'invalidateDatabaseTablesCacheForOptionChange'], 10, 1);
        self::$databaseTablesCacheInvalidationHooksRegistered = true;
    }

    /**
     * A versioned transient makes a completed option change immediately select a
     * new cache key. This avoids relying on delete-before-write ordering when
     * concurrent admin requests rebuild the database-table inventory.
     *
     * @param mixed $optionName
     * @param mixed $unused
     * @param mixed $unusedSecond
     */
    public static function invalidateDatabaseTablesCacheForOptionChange($optionName, $unused = null, $unusedSecond = null): void
    {
        unset($unused, $unusedSecond);
        if ((string) $optionName === self::DATABASE_TABLES_CACHE_VERSION_OPTION || !function_exists('get_option') || !function_exists('update_option')) {
            return;
        }

        $current = max(1, (int) get_option(self::DATABASE_TABLES_CACHE_VERSION_OPTION, 1));
        update_option(self::DATABASE_TABLES_CACHE_VERSION_OPTION, $current + 1, false);

        if (self::$instance instanceof self) {
            self::$instance->databaseTablesCache = null;
        }

        // Remove the pre-versioned key on upgrade; newer keys expire naturally.
        if (function_exists('delete_transient')) {
            delete_transient(self::DATABASE_TABLES_CACHE_TRANSIENT);
        }
    }

    private function __clone()
    {
    }

    public function __wakeup(): void
    {
        throw new \LogicException('SHIELD-X plugin singleton cannot be unserialized.');
    }

    public static function instance(): self
    {
        if (!self::$instance) {
            self::$instance = new self();
        }

        return self::$instance;
    }

    public function boot(): void
    {
        $this->maybeUpgradeSchema();

        $adminPage = new AdminPageLoader($this);
        (new VirtualPatchAdmin())->register();
        $privacyManager = PrivacyManager::fromWordPress();
        $this->lockdown()->boot();
        if (self::isAdminOrCli()) {
            $this->updateCoordinator()->register();
        }
        (new Hardening($this->hardeningSettings()))->boot();
        $this->loginSecurity()->boot();
        $this->humanChallenge()->boot();
        NotificationManager::fromWordPress()->register();
        UserProfiler::registerHooks();
        add_filter(SchedulerBridge::TASKS_FILTER, [$this, 'registerSchedulerTasks']);
        add_filter(SchedulerBridge::TASK_DISPLAY_FILTER, [$this, 'hydrateSchedulerTaskDisplay']);

        add_filter('cron_schedules', [ScheduledScanRunner::class, 'cronSchedules']);
        add_filter('cron_schedules', [ScheduledBackupRunner::class, 'cronSchedules']);
        add_filter('cron_schedules', [ScheduledRestorePointRunner::class, 'cronSchedules']);
        add_filter('cron_request', [$this, 'repairLocalCronLoopbackRequest'], 10, 2);

        add_action('init', [$this, 'maybeRunEmergencyRecovery'], -30);
        add_action('init', [$this, 'maybeRunExternalScan'], -20);
        add_action('init', [$this, 'maybeRunExternalBackup'], -20);
        add_action('init', [$this, 'maybeRunExternalRestorePoint'], -20);
        add_action('init', [$this, 'bootstrapAddonRuntimeHooks'], -10);
        // admin-post.php dispatches actions before the later admin_init hook is
        // reliable for add-on handlers. This remains a narrow, action-prefixed
        // preflight and does not load extensions on normal public requests.
        add_action('init', [$this, 'bootstrapAddonRequestHooks'], -9);
        add_action('init', [$this, 'inspectRequest'], 0);
        add_action('init', [Activator::class, 'runDeferredActivationTasks'], 20);
        add_action('init', [$this, 'maybeReconcileCoreSchedules'], 35);
        add_action(ReputationFeed::HOOK, [ReputationFeed::class, 'runScheduled']);
        add_action('admin_menu', [$adminPage, 'register']);
        add_action('admin_bar_menu', [$adminPage, 'renderAdminToolbarStatus'], 80);
        add_action('wp_dashboard_setup', [$adminPage, 'registerDashboardWidgets']);
        add_filter('get_user_option_meta-box-order_dashboard', [$adminPage, 'normalizeDashboardWidgetOrder']);
        add_action('admin_head', [$adminPage, 'suppressForeignAdminNotices'], 0);
        add_action('admin_enqueue_scripts', [$adminPage, 'enqueue']);
        if (self::isAdminOrCli()) {
            add_action('wp_ajax_shieldx_dashboard_overview', [$adminPage, 'ajaxDashboardOverview']);
            add_action('wp_ajax_shieldx_admin_page_content', [$adminPage, 'ajaxAdminPageContent']);
            add_action('wp_ajax_shieldx_overview_card_visibility', [$adminPage, 'ajaxOverviewCardVisibility']);
            add_action('wp_ajax_shieldx_prepare_hardening_preflight', [$adminPage, 'ajaxPrepareHardeningPreflight']);
            add_action('admin_post_shieldx_save_waf_settings', [$adminPage, 'saveWafSettings']);
            add_action('admin_post_shieldx_waf_access_action', [$adminPage, 'wafAccessAction']);
            add_action('admin_post_shieldx_clear_waf_access_table', [$adminPage, 'clearWafAccessTable']);
            add_action('admin_post_shieldx_clear_waf_hits', [$adminPage, 'clearWafHits']);
            add_action('admin_post_shieldx_waf_hit_action', [$adminPage, 'wafHitAction']);
            add_action('admin_post_shieldx_save_human_challenge_settings', [$adminPage, 'saveHumanChallengeSettings']);
            add_action('admin_post_shieldx_toggle_module', [$adminPage, 'toggleModule']);
            add_action('admin_post_shieldx_activate_license', [$adminPage, 'activateLicense']);
            add_action('admin_post_shieldx_retry_license_activation', [$adminPage, 'retryLicenseActivation']);
            add_action('admin_post_shieldx_validate_license', [$adminPage, 'validateLicense']);
            add_action('admin_post_shieldx_deactivate_license', [$adminPage, 'deactivateLicense']);
            add_action('admin_post_shieldx_apply_setup_mode', [$adminPage, 'applySetupMode']);
            add_action('admin_post_shieldx_activity_quick_block', [$adminPage, 'activityQuickBlock']);
            add_action('admin_post_shieldx_save_user_profiler', [$adminPage, 'saveUserProfilerSettings']);
            add_action('admin_post_shieldx_save_hardening_settings', [$adminPage, 'saveHardeningSettings']);
            add_action('admin_post_shieldx_save_lockdown_settings', [$adminPage, 'saveLockdownSettings']);
            add_action('admin_post_shieldx_continue_permission_fix', [$adminPage, 'continuePermissionFix']);
            add_action('admin_post_shieldx_save_scan_settings', [$adminPage, 'saveScanSettings']);
            add_action('admin_post_shieldx_run_scan', [$adminPage, 'runScan']);
            add_action('admin_post_shieldx_continue_scan', [$adminPage, 'continueScan']);
            add_action('admin_post_shieldx_run_vulnerability_scan', [$adminPage, 'runVulnerabilityScan']);
            add_action('admin_post_shieldx_prepare_core_release', [$adminPage, 'prepareCoreRelease']);
            add_action('admin_post_shieldx_continue_core_release', [$adminPage, 'continueCoreRelease']);
            add_action('admin_post_shieldx_prepare_plugin_packages', [$adminPage, 'preparePluginPackages']);
            add_action('admin_post_shieldx_continue_plugin_packages', [$adminPage, 'continuePluginPackages']);
            add_action('admin_post_shieldx_upload_plugin_package', [$adminPage, 'uploadPluginPackage']);
            add_action('admin_post_shieldx_trust_installed_plugin', [$adminPage, 'trustInstalledPluginPackage']);
            add_action('admin_post_shieldx_upload_theme_package', [$adminPage, 'uploadThemePackage']);
            add_action('admin_post_shieldx_trust_installed_theme', [$adminPage, 'trustInstalledThemePackage']);
            add_action('admin_post_shieldx_restore_core_file', [$adminPage, 'restoreCoreFile']);
            add_action('admin_post_shieldx_quarantine_scan_issue', [$adminPage, 'quarantineScanIssue']);
            add_action('admin_post_shieldx_restore_quarantined_issue', [$adminPage, 'restoreQuarantinedIssue']);
            add_action('admin_post_shieldx_ignore_scan_issue', [$adminPage, 'ignoreScanIssue']);
            add_action('admin_post_shieldx_remove_scan_ignore', [$adminPage, 'removeScanIgnore']);
            add_action('admin_post_shieldx_repair_htaccess', [$adminPage, 'repairHtaccess']);
            add_action('admin_post_shieldx_bulk_scan_action', [$adminPage, 'bulkScanAction']);
            add_action('admin_post_shieldx_trust_scanner_file', [$adminPage, 'trustScannerFile']);
            add_action('admin_post_shieldx_recheck_scanner_file', [$adminPage, 'recheckScannerFile']);
            add_action('admin_post_shieldx_save_compared_plugin_file', [$adminPage, 'saveComparedPluginFile']);
            add_action('admin_post_shieldx_cleanup_config_file', [$adminPage, 'cleanupConfigFile']);
            add_action('admin_post_shieldx_remediate_db_issue', [$adminPage, 'remediateDatabaseIssue']);
            add_action('admin_post_shieldx_restore_db_backup', [$adminPage, 'restoreDatabaseBackup']);
            add_action('admin_post_shieldx_recheck_db_issue', [$adminPage, 'recheckDatabaseIssue']);
            add_action('admin_post_shieldx_create_restore_point', [$adminPage, 'createRestorePoint']);
            add_action('admin_post_shieldx_continue_backup', [$adminPage, 'continueBackup']);
            add_action('admin_post_shieldx_upload_restore_point_google', [$adminPage, 'uploadRestorePointGoogle']);
            add_action('admin_post_shieldx_restore_restore_point', [$adminPage, 'restoreRestorePoint']);
            add_action('admin_post_shieldx_download_restore_handoff', [$adminPage, 'downloadRestoreHandoff']);
            add_action('admin_post_shieldx_delete_restore_point', [$adminPage, 'deleteRestorePoint']);
            add_action('admin_post_shieldx_delete_restore_points_before', [$adminPage, 'deleteRestorePointsBefore']);
            add_action('admin_post_shieldx_create_database_backup', [$adminPage, 'createDatabaseBackup']);
            add_action('admin_post_shieldx_delete_database_backups_before', [$adminPage, 'deleteDatabaseBackupsBefore']);
            add_action('admin_post_shieldx_save_backup_settings', [$adminPage, 'saveBackupSettings']);
            add_action('admin_post_shieldx_save_file_type_settings', [$adminPage, 'saveFileTypeSettings']);
            add_action('admin_post_shieldx_save_backup_storage', [$adminPage, 'saveBackupStorage']);
            add_action('admin_post_shieldx_google_drive_oauth_save', [$adminPage, 'saveGoogleDriveOAuthApp']);
            add_action('admin_post_shieldx_google_drive_auth_start', [$adminPage, 'startGoogleDriveAuth']);
            add_action('admin_post_shieldx_google_drive_auth_complete', [$adminPage, 'completeGoogleDriveAuth']);
            add_action('admin_post_shieldx_google_drive_disconnect', [$adminPage, 'disconnectGoogleDrive']);
            add_action('admin_post_shieldx_google_drive_test', [$adminPage, 'testGoogleDriveConnection']);
            add_action('admin_post_shieldx_save_restore_point_schedule', [$adminPage, 'saveRestorePointSchedule']);
            add_action('admin_post_shieldx_restore_manual_db_backup', [$adminPage, 'restoreManualDatabaseBackup']);
            add_action('admin_post_shieldx_restore_shadow_copy', [$adminPage, 'restoreShadowCopy']);
            add_action('admin_post_shieldx_confirm_shadow_copy', [$adminPage, 'confirmShadowCopy']);
            add_action('admin_post_shieldx_confirm_shadow_session', [$adminPage, 'confirmShadowSession']);
            add_action('admin_post_shieldx_save_login_security', [$adminPage, 'saveLoginSecuritySettings']);
            add_action('admin_post_shieldx_revoke_login_sessions', [$adminPage, 'revokeLoginSessions']);
            add_action('admin_post_shieldx_export_settings', [$adminPage, 'exportSettings']);
            add_action('admin_post_shieldx_import_settings', [$adminPage, 'importSettings']);
            add_action('admin_post_shieldx_save_log_rotation', [$adminPage, 'saveLogRotationSettings']);
            add_action('admin_post_shieldx_save_uninstall_settings', [$adminPage, 'saveUninstallSettings']);
            add_action('admin_post_shieldx_scan_log_rotation', [$adminPage, 'scanLogRotationFiles']);
            add_action('admin_post_shieldx_cleanup_temp_files', [$adminPage, 'cleanupTemporaryFiles']);
            add_action('admin_post_shieldx_reveal_emergency_recovery_url', [$adminPage, 'revealEmergencyRecoveryUrl']);
            add_action('admin_post_shieldx_regenerate_emergency_recovery_url', [$adminPage, 'regenerateEmergencyRecoveryUrl']);
            add_action('admin_post_shieldx_enable_2fa', [$adminPage, 'enableTwoFactor']);
            add_action('admin_post_shieldx_regenerate_2fa_codes', [$adminPage, 'regenerateTwoFactorCodes']);
            add_action('admin_post_shieldx_disable_2fa', [$adminPage, 'disableTwoFactor']);
            add_action('admin_post_shieldx_disable_admin_2fa', [$adminPage, 'disableAdminTwoFactor']);
            add_action('admin_post_shieldx_request_2fa_reset', [$adminPage, 'requestTwoFactorReset']);
            add_action('admin_post_shieldx_confirm_2fa_reset', [$adminPage, 'confirmTwoFactorReset']);
        }
        add_action('rest_api_init', [new StatusController($this), 'register']);
        add_action('admin_init', [$this, 'bootstrapAddonRequestHooks'], 0);
        add_action('admin_init', [$this, 'validateCurrentAdminSession'], 5);
        add_action('admin_init', [$this, 'registerPrivacyPolicyContent']);
        add_action('admin_init', [$this, 'registerAdminAuditHooks']);
        add_filter('wp_privacy_personal_data_exporters', [$privacyManager, 'registerExporters']);
        add_filter('wp_privacy_personal_data_erasers', [$privacyManager, 'registerErasers']);
        add_action('admin_notices', [$this, 'renderPluginFilesystemNotice']);
        add_action('shieldx_hourly_maintenance', [SchedulerBridge::class, 'selfHeal'], 1);
        add_action('shieldx_hourly_maintenance', [$this, 'maintenance']);
        add_action('shieldx_temp_cleanup', [$this, 'runScheduledTempCleanup']);
        add_action('shieldx_settings_imported', [$this, 'clearStatusCache']);
        add_action('shieldx_settings_updated', [$this, 'clearStatusCache']);
        add_action('shieldx_settings_updated', [SchedulerBridge::class, 'refresh']);
        add_action(ScheduledScanRunner::HOOK, [$this, 'runScheduledScan'], 10, 1);
        add_action(ScheduledBackupRunner::HOOK, [$this, 'runScheduledBackup']);
        add_action(ScheduledRestorePointRunner::HOOK, [$this, 'runScheduledRestorePoint']);
        add_filter('authenticate', [$this, 'checkLoginLockout'], 1000, 3);
        add_filter('authenticate', [$this, 'recordAuthenticationFailure'], 1001, 3);
        add_action('wp_login_failed', [$this, 'recordLoginFailure'], 10, 1);
        add_action('wp_login', [$this, 'clearLoginFailures'], 10, 2);
        add_action('wp_login', [$this, 'recordUserLogin'], 10, 2);
        add_action('wp_logout', [$this, 'recordUserLogout'], 10, 1);

        if (defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI')) {
            \WP_CLI::add_command('shield-x status', new StatusCommand($this));
            \WP_CLI::add_command('shield-x lockdown', new LockdownCommand($this));
            \WP_CLI::add_command('shield-x scan', new ScanCommand());
            \WP_CLI::add_command('shield-x waf', new WafCommand());
            \WP_CLI::add_command('shield-x rules', new WafCommand());
            \WP_CLI::add_command('shield-x log', new LogCommand());
            \WP_CLI::add_command('shield-x db', new DatabaseCommand());
        }
    }

    public function bootstrapAddonRequestHooks(): void
    {
        if (!$this->isShieldXAdminDispatchRequest()) {
            return;
        }

        AddonRegistry::fromWordPress()->manifests();
    }

    public function bootstrapAddonRuntimeHooks(): void
    {
        if (!$this->shouldBootstrapAddonRuntimeHooks()) {
            return;
        }

        AddonRegistry::fromWordPress()->manifests();
    }

    public function registerAdminAuditHooks(): void
    {
        if ($this->adminAuditHooksRegistered) {
            return;
        }

        $this->adminAuditHooksRegistered = true;
        $adminShadowScanner = AdminChangeShadowScanner::fromWordPress();
        $adminShadowScanner->register();
        add_action('transition_post_status', [$this, 'recordPostStatusTransition'], 10, 3);
        add_action('post_updated', [$this, 'recordPostUpdated'], 10, 3);
        add_action('before_delete_post', [$this, 'recordPostDeleted'], 10, 2);
        add_action('added_post_meta', [$this, 'recordPostMetaAdded'], 10, 4);
        add_action('updated_post_meta', [$this, 'recordPostMetaUpdated'], 10, 4);
        add_action('deleted_post_meta', [$this, 'recordPostMetaDeleted'], 10, 4);
        add_action('set_object_terms', [$this, 'recordObjectTermsSet'], 10, 6);
        add_action('created_term', [$this, 'recordTermCreated'], 10, 3);
        add_action('edited_term', [$this, 'recordTermUpdated'], 10, 3);
        add_action('delete_term', [$this, 'recordTermDeleted'], 10, 5);
        add_action('wp_create_nav_menu', [$this, 'recordMenuCreated'], 10, 2);
        add_action('wp_update_nav_menu', [$this, 'recordMenuUpdated'], 10, 2);
        add_action('wp_delete_nav_menu', [$this, 'recordMenuDeleted'], 10, 1);
        add_action('wp_update_nav_menu_item', [$this, 'recordMenuItemUpdated'], 10, 3);
        add_action('added_option', [$this, 'recordWordPressSettingAdded'], 10, 2);
        add_action('updated_option', [$this, 'recordWordPressSettingUpdated'], 10, 3);
        add_action('deleted_option', [$this, 'recordWordPressSettingDeleted'], 10, 1);
        add_action('user_register', [$this, 'recordUserRegistered'], 10, 2);
        add_action('profile_update', [$this, 'recordUserProfileUpdated'], 10, 3);
        add_action('deleted_user', [$this, 'recordUserDeleted'], 10, 3);
        add_action('set_user_role', [$this, 'recordUserRoleSet'], 10, 3);
        add_action('after_password_reset', [$this, 'recordPasswordReset'], 10, 2);
        add_action('destroy_other_sessions', [$this, 'recordOtherSessionsDestroyed'], 10, 1);
        add_action('activated_plugin', [$this, 'recordPluginActivated'], 10, 2);
        add_action('deactivated_plugin', [$this, 'recordPluginDeactivated'], 10, 2);
        add_action('deleted_plugin', [$this, 'recordPluginDeleted'], 10, 2);
        add_action('upgrader_process_complete', [$this, 'recordPluginUpgraderAction'], 10, 2);
        add_action('switch_theme', [$this, 'recordThemeSwitched'], 10, 3);
        add_action('deleted_theme', [$this, 'recordThemeDeleted'], 10, 1);
        add_action('wp_initialize_site', [$this, 'recordNetworkSiteCreated'], 10, 2);
        add_action('wp_delete_site', [$this, 'recordNetworkSiteDeleted'], 10, 1);
        add_action('archive_blog', [$this, 'recordNetworkSiteArchived'], 10, 1);
        add_action('unarchive_blog', [$this, 'recordNetworkSiteUnarchived'], 10, 1);
        add_action('make_spam_blog', [$this, 'recordNetworkSiteMarkedSpam'], 10, 1);
        add_action('make_ham_blog', [$this, 'recordNetworkSiteMarkedHam'], 10, 1);
        add_action('add_user_to_blog', [$this, 'recordUserAddedToSite'], 10, 3);
        add_action('remove_user_from_blog', [$this, 'recordUserRemovedFromSite'], 10, 3);
    }

    private function isShieldXAdminDispatchRequest(): bool
    {
        $script = '';
        if (isset($_SERVER['SCRIPT_NAME']) && is_scalar($_SERVER['SCRIPT_NAME'])) {
            $script = basename(str_replace('\\', '/', sanitize_text_field(wp_unslash((string) $_SERVER['SCRIPT_NAME']))));
        }

        $scriptIsAdminDispatch = $script === 'admin-post.php' || $script === 'admin-ajax.php';

        $action = '';
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only dispatch preflight; target handlers perform capability and nonce checks.
        if (isset($_POST['action']) && is_scalar($_POST['action'])) {
            // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only dispatch preflight; target handlers perform capability and nonce checks.
            $action = sanitize_key(wp_unslash((string) $_POST['action']));
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only dispatch preflight; target handlers perform capability and nonce checks.
        } elseif (isset($_GET['action']) && is_scalar($_GET['action'])) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only dispatch preflight; target handlers perform capability and nonce checks.
            $action = sanitize_key(wp_unslash((string) $_GET['action']));
        }

        if (strpos($action, 'shieldx_') !== 0) {
            return false;
        }

        // Some server integrations normalize SCRIPT_NAME before WordPress
        // loads admin-post.php. The action prefix plus is_admin() retains a
        // narrow admin dispatch fallback while preserving the script fast path.
        return ($scriptIsAdminDispatch || (function_exists('is_admin') && is_admin()))
            && self::currentUserCanManageShieldX();
    }

    private function shouldBootstrapAddonRuntimeHooks(): bool
    {
        if (function_exists('wp_doing_cron') && wp_doing_cron()) {
            return true;
        }

        if (function_exists('is_admin') && is_admin()) {
            return false;
        }

        return true;
    }

    /**
     * WordPress spawns due cron by making an HTTP request back to site_url().
     * In local Docker installs, that URL is commonly localhost:PORT for the
     * browser, while the WordPress container itself listens on localhost:80.
     *
     * @param array<string,mixed> $request
     * @param mixed $doingWpCron
     * @return array<string,mixed>
     */
    public function repairLocalCronLoopbackRequest(array $request, $doingWpCron = null): array
    {
        unset($doingWpCron);

        if (!defined('WP_ENVIRONMENT_TYPE') || (string) WP_ENVIRONMENT_TYPE !== 'local') {
            return $request;
        }

        $url = isset($request['url']) && is_string($request['url']) ? $request['url'] : '';
        $parts = wp_parse_url($url);
        if (!is_array($parts)) {
            return $request;
        }

        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));
        $path = (string) ($parts['path'] ?? '');
        $port = isset($parts['port']) ? (int) $parts['port'] : 0;
        if ($scheme !== 'http' || !in_array($host, ['localhost', '127.0.0.1', '::1'], true) || basename($path) !== 'wp-cron.php' || $port <= 0 || $port === 80) {
            return $request;
        }

        $query = isset($parts['query']) && is_string($parts['query']) && $parts['query'] !== '' ? '?' . $parts['query'] : '';
        $request['url'] = 'http://127.0.0.1' . ($path !== '' ? $path : '/wp-cron.php') . $query;

        if (!isset($request['args']) || !is_array($request['args'])) {
            $request['args'] = [];
        }
        if (!isset($request['args']['headers']) || !is_array($request['args']['headers'])) {
            $request['args']['headers'] = [];
        }
        $request['args']['headers']['Host'] = (string) ($parts['host'] ?? 'localhost') . ':' . (string) $port;

        return $request;
    }

    public function renderPluginFilesystemNotice(): void
    {
        if (!function_exists('get_current_screen') || !self::currentUserCanManageShieldX()) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || $screen->id !== 'plugins') {
            return;
        }

        $lockdown = $this->lockdown()->status();
        $policy = isset($lockdown['policy']) && is_array($lockdown['policy']) ? $lockdown['policy'] : [];
        $pluginsDir = WordPressPaths::pluginParentDir();
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Diagnostic-only check; SHIELD-X is not mutating files here.
        $pluginsWritable = $pluginsDir !== '' && is_dir($pluginsDir) && is_writable($pluginsDir);
        $readOnlyPluginFiles = $this->readOnlyPluginFilesForNotice($pluginsDir);
        $lockdownActive = !empty($lockdown['enabled']);
        $fileModsLocked = function_exists('wp_is_file_mod_allowed') && !wp_is_file_mod_allowed('shieldx_plugin_filesystem_notice');

        if (!$lockdownActive && !$fileModsLocked && $pluginsWritable && empty($readOnlyPluginFiles)) {
            return;
        }

        $reasons = [];
        if ($lockdownActive) {
            $reasons[] = __('SHIELD-X Lockdown is active and may be keeping plugin files read-only.', 'xerotact-shield-x-security');
        }
        if ($fileModsLocked) {
            $reasons[] = __('DISALLOW_FILE_MODS is active, so WordPress plugin install, update, and delete operations can fail.', 'xerotact-shield-x-security');
        } elseif (!empty($policy['disable_file_mods'])) {
            $reasons[] = __('The Lockdown policy is configured to enable DISALLOW_FILE_MODS when Lockdown is applied.', 'xerotact-shield-x-security');
        }
        if (!$pluginsWritable) {
            $reasons[] = __('The WordPress plugins directory is not writable by PHP.', 'xerotact-shield-x-security');
        }
        if (!empty($readOnlyPluginFiles)) {
            /* translators: %s: comma-separated plugin file paths. */
            $reasons[] = sprintf(__('Some plugin entry files are read-only: %s.', 'xerotact-shield-x-security'), implode(', ', $readOnlyPluginFiles));
        }

        $lockdownUrl = admin_url('admin.php?page=shield-x-lockdown');
        ?>
        <div class="notice notice-warning">
            <p>
                <strong><?php echo esc_html__('Plugin delete/update may be blocked by filesystem protection.', 'xerotact-shield-x-security'); ?></strong>
                <?php echo esc_html__('If WordPress says it could not fully remove a plugin, review these checks before retrying:', 'xerotact-shield-x-security'); ?>
            </p>
            <ul style="list-style:disc;margin-left:1.5em;">
                <?php foreach ($reasons as $reason) : ?>
                    <li><?php echo esc_html($reason); ?></li>
                <?php endforeach; ?>
            </ul>
            <p>
                <?php echo esc_html__('To continue, open SHIELD-X Lockdown, disable or revert Lockdown for the maintenance window, delete or update the plugin, then re-enable Lockdown when finished.', 'xerotact-shield-x-security'); ?>
                <a href="<?php echo esc_url($lockdownUrl); ?>"><?php echo esc_html__('Open SHIELD-X Lockdown', 'xerotact-shield-x-security'); ?></a>
            </p>
        </div>
        <?php
    }

    private static function manageCapability(): string
    {
        return defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';
    }

    private static function currentUserCanManageShieldX(): bool
    {
        return function_exists('current_user_can') && current_user_can(self::manageCapability());
    }

    /**
     * @return array<int,string>
     */
    private function readOnlyPluginFilesForNotice(string $pluginsDir): array
    {
        if ($pluginsDir === '' || !function_exists('get_plugins')) {
            return [];
        }

        $cacheKey = 'shieldx_plugin_filesystem_notice_' . substr(hash('sha256', str_replace('\\', '/', $pluginsDir)), 0, 24);
        if (function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached)) {
                return array_values(array_filter(array_map('strval', $cached)));
            }
        }

        $plugins = get_plugins();
        if (!is_array($plugins)) {
            return [];
        }

        $readOnly = [];
        foreach (array_keys($plugins) as $pluginFile) {
            if (!is_string($pluginFile) || $pluginFile === '') {
                continue;
            }

            $relative = str_replace('\\', '/', $pluginFile);
            if (strpos($relative, '..') !== false || strpos($relative, "\0") !== false) {
                continue;
            }

            $path = rtrim($pluginsDir, '/\\') . '/' . $relative;
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Diagnostic-only check for a Plugins screen warning.
            if (is_file($path) && !is_writable($path)) {
                $readOnly[] = $relative;
            }

            if (count($readOnly) >= 5) {
                break;
            }
        }

        if (function_exists('set_transient')) {
            set_transient($cacheKey, $readOnly, self::PLUGIN_FILESYSTEM_NOTICE_CACHE_TTL_SECONDS);
        }

        return $readOnly;
    }

    public function inspectRequest(): void
    {
        if (function_exists('wp_doing_cron') && wp_doing_cron()) {
            return;
        }

        // Command-line processes do not represent HTTP requests. In particular,
        // direct PHP bootstrap scripts do not define WP_CLI, but still need to
        // load WordPress without being evaluated as an empty public request.
        if (PHP_SAPI === 'cli') {
            return;
        }

        if (defined('WP_CLI') && WP_CLI) {
            return;
        }

        if (self::isEarlyStaticAssetBypassUri($this->serverText('REQUEST_URI'))) {
            return;
        }

        $request = MinimalWaf::requestFromGlobals($this->wafSettings(), $this->waf()->needsRequestBody($this->serverText('REQUEST_METHOD'), $_SERVER));

        if ((string) ($request['ip'] ?? 'unknown') === 'unknown') {
            $this->logger()->warning('waf.ip_unknown', [
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
            ]);
            $request['ip'] = '0.0.0.0';
        }

        // Avoid constructing the challenge service on ordinary requests. The
        // handler remains mandatory whenever its signed submission field is present.
        if (self::hasHumanChallengeSubmission() && $this->humanChallenge()->maybeHandleChallengeSubmission($request)) {
            return;
        }

        if (self::isInspectionBypassRequest($request)) {
            return;
        }

        // Authenticated SHIELD-X administrators should not be blocked by
        // SHIELD-X inspection. This intentionally checks the manage capability,
        // not merely whether the visitor is logged in, so customers/subscribers
        // remain protected.
        if ($this->isTrustedAdminRequest($request)) {
            return;
        }

        // Profiling is opt-in, so avoid creating its database-backed service on
        // the default public-request path.
        if (UserProfiler::enabled()) {
            $this->userProfiler()->recordRequest($request);
        }

        $lockdownDecision = $this->lockdown()->assessRequest($request);
        if (($lockdownDecision['action'] ?? '') === 'block') {
            $context = [
                'mode' => 'lockdown',
                'action' => 'block',
                'reason' => (string) ($lockdownDecision['reason'] ?? ''),
                'matched_value' => (string) ($lockdownDecision['matched_value'] ?? ''),
                'rules' => ['SHIELDX-LOCKDOWN'],
                'rule_details' => [],
                'score' => 100,
                'engine_version' => 'lockdown-v1',
                'ip' => $request['ip'] ?? 'unknown',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
            ];
            $this->logger()->warning('lockdown.block', $context);
            $this->blockWafRequest($context);
        }

        $rateLimit = $this->rateLimiter()->assessRequest($request);

        if (!empty($rateLimit['limited']) && !empty($rateLimit['log_event'])) {
            $this->logger()->warning('rate.limit', [
                'profile' => (string) ($rateLimit['profile'] ?? 'global'),
                'mode' => (string) ($rateLimit['mode'] ?? ''),
                'action' => (string) ($rateLimit['action'] ?? ''),
                'reason' => (string) ($rateLimit['reason'] ?? ''),
                'retry_after' => (int) ($rateLimit['retry_after'] ?? 0),
                'count' => (int) ($rateLimit['count'] ?? 0),
                'limit' => (int) ($rateLimit['limit'] ?? 0),
                'window_seconds' => (int) ($rateLimit['window_seconds'] ?? 0),
                'burst_count' => (int) ($rateLimit['burst_count'] ?? 0),
                'burst_limit' => (int) ($rateLimit['burst_limit'] ?? 0),
                'burst_window_seconds' => (int) ($rateLimit['burst_window_seconds'] ?? 0),
                'ip' => $request['ip'] ?? 'unknown',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
            ]);

        }

        if (!empty($rateLimit['limited']) && ($rateLimit['action'] ?? '') === 'block') {
            $this->blockRateLimitedRequest($rateLimit);
        }

        $wafDecision = $this->wafSettings()->assessRequest($request);
        if (($wafDecision['action'] ?? '') === 'allow') {
            $this->logger()->info('waf.allow', [
                'reason' => (string) ($wafDecision['reason'] ?? ''),
                'matched_value' => (string) ($wafDecision['matched_value'] ?? ''),
                'ip' => $request['ip'] ?? 'unknown',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
            ]);
            return;
        }

        if (($wafDecision['action'] ?? '') === 'block') {
            $context = [
                'mode' => 'enforce',
                'action' => 'block',
                'reason' => (string) ($wafDecision['reason'] ?? ''),
                'matched_value' => (string) ($wafDecision['matched_value'] ?? ''),
                'temporary_id' => (string) ($wafDecision['temporary_id'] ?? ''),
                'expires_at' => (int) ($wafDecision['expires_at'] ?? 0),
                'country' => (string) ($wafDecision['country'] ?? ''),
                'rules' => [],
                'rule_details' => [],
                'score' => 0,
                'engine_version' => $this->waf()->engineVersion(),
                'ip' => $request['ip'] ?? 'unknown',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
            ];
            $this->logger()->warning('waf.block', $context);
            $this->blockWafRequest($context);
        }

        if ($this->wafSettings()->isPluginWhitelistedRequest($request)) {
            return;
        }

        $result = $this->waf()->evaluate($request);

        if (!empty($result['budget_exhausted'])) {
            $this->logger()->warning('waf.budget_exhausted', [
                'mode' => (string) ($result['mode'] ?? ''),
                'action' => (string) ($result['action'] ?? ''),
                'pattern_evaluations' => (int) ($result['pattern_evaluations'] ?? 0),
                'budget_exhausted_reason' => (string) ($result['budget_exhausted_reason'] ?? ''),
                'engine_version' => (string) ($result['engine_version'] ?? ''),
                'analysis_time_ms' => (float) ($result['analysis_time_ms'] ?? 0),
            ]);
        }

        if (!empty($result['pcre_errors'])) {
            $this->logger()->warning('waf.pcre_error', [
                'mode' => $result['mode'] ?? '',
                'pcre_errors' => $result['pcre_errors'],
                'engine_version' => $result['engine_version'] ?? '',
                'ip' => $request['ip'] ?? 'unknown',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
            ]);
        }

        if ($result['matched'] === true) {
            $this->logger()->warning('waf.match', [
                'mode' => $result['mode'],
                'rules' => $result['rules'],
                'rule_details' => $result['rule_details'],
                'action' => $result['action'],
                'would_block' => $result['would_block'] ?? false,
                'blocking_rules' => $result['blocking_rules'] ?? [],
                'score' => $result['score'],
                'score_threshold_action' => $result['score_threshold_action'] ?? 'none',
                'body_truncated' => !empty($result['body_truncated']),
                'budget_exhausted' => !empty($result['budget_exhausted']),
                'budget_exhausted_reason' => (string) ($result['budget_exhausted_reason'] ?? ''),
                'pcre_errors' => $result['pcre_errors'] ?? [],
                'inspected_surfaces' => $result['inspected_surfaces'] ?? [],
                'engine_version' => $result['engine_version'] ?? '',
                'analysis_time_ms' => $result['analysis_time_ms'] ?? 0,
                'ip' => $request['ip'] ?? 'unknown',
                'browser' => $request['browser'] ?? 'Unknown',
                'user_agent' => $request['user_agent'] ?? '',
                'method' => $request['method'] ?? '',
                'uri' => $request['uri'] ?? '',
            ]);

            if (($result['action'] ?? '') === 'block') {
                $temporaryBlock = (string) ($result['mode'] ?? '') === 'temporary'
                    ? $this->syncWafRuleTemporaryBlock($request, $result)
                    : [];
                $blockContext = [
                    'mode' => $result['mode'],
                    'action' => $result['action'],
                    'reason' => !empty($temporaryBlock) ? 'temporary_rule_match' : 'rule_match',
                    'rules' => $result['rules'],
                    'rule_details' => $result['rule_details'],
                    'blocking_rules' => $result['blocking_rules'] ?? [],
                    'temporary_id' => (string) ($temporaryBlock['temporary_id'] ?? ''),
                    'expires_at' => (int) ($temporaryBlock['expires_at'] ?? 0),
                    'score' => $result['score'],
                    'score_threshold_action' => $result['score_threshold_action'] ?? 'none',
                    'body_truncated' => !empty($result['body_truncated']),
                    'budget_exhausted' => !empty($result['budget_exhausted']),
                    'budget_exhausted_reason' => (string) ($result['budget_exhausted_reason'] ?? ''),
                    'inspected_surfaces' => $result['inspected_surfaces'] ?? [],
                    'engine_version' => $result['engine_version'] ?? '',
                    'ip' => $request['ip'] ?? 'unknown',
                    'browser' => $request['browser'] ?? 'Unknown',
                    'user_agent' => $request['user_agent'] ?? '',
                    'method' => $request['method'] ?? '',
                    'uri' => $request['uri'] ?? '',
                ];
                $this->logger()->warning('waf.block', $blockContext);
                $this->blockWafRequest($blockContext);
            }

            if ($this->humanChallenge()->shouldChallengeRequest($request, $result)) {
                $this->humanChallenge()->renderWafChallenge($request, $result);
            }

            return;
        }

        $this->recordNormalVisit($request);
    }

    private static function hasHumanChallengeSubmission(): bool
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only preflight; HumanChallenge verifies the signed submission before changing state.
        return isset($_POST['shieldx_cap_challenge']) && is_scalar($_POST['shieldx_cap_challenge']);
    }

    private function isTrustedAdminRequest(array $request): bool
    {
        unset($request);

        return self::currentUserCanManageShieldX();
    }

    /**
     * Records eligible public front-end page views for overview chart trends.
     *
     * @param array<string,mixed> $request
     */
    private function recordNormalVisit(array $request): void
    {
        $method = strtoupper((string) ($request['method'] ?? ''));
        if (!in_array($method, ['GET', 'HEAD'], true)) {
            return;
        }

        $uri = (string) ($request['uri'] ?? '');
        $requestPath = isset($request['path']) && is_scalar($request['path']) ? (string) $request['path'] : '';
        $pathValue = $requestPath !== ''
            ? $requestPath
            : (function_exists('wp_parse_url') ? wp_parse_url($uri, PHP_URL_PATH) : '');
        $path = strtolower(is_string($pathValue) && $pathValue !== '' ? $pathValue : '/');
        $assetPath = $path !== '/' ? rtrim($path, '/') : $path;

        if (
            strpos($path, '/wp-admin') === 0
            || strpos($path, '/wp-login.php') === 0
            || strpos($path, '/shieldx-cap/') === 0
            || strpos($path, '/index.php/shieldx-cap/') === 0
            || self::queryFlagExists($uri, 'shieldx_cap_site')
            || $path === '/wp-cron.php'
            || preg_match('~\.(?:css|js|map|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|mp4|mp3|pdf|zip)$~i', $assetPath)
        ) {
            return;
        }

        if (in_array(rtrim($path, '/'), AddonRegistry::fromWordPress()->externalPaths(), true)) {
            return;
        }

        $this->logger()->info('visit.page', [
            'ip' => $request['ip'] ?? 'unknown',
            'browser' => $request['browser'] ?? 'Unknown',
            'method' => $method,
            'path' => $path,
        ]);
    }

    public function registerPrivacyPolicyContent(): void
    {
        if (!function_exists('wp_add_privacy_policy_content')) {
            return;
        }

        $content = '<p>' . esc_html__(
            'SHIELD-X can collect local security events such as firewall hits, login activity, important post or page changes, user account changes, WordPress setting changes, plugin lifecycle changes, blocked requests, scanner findings, administrative lockdown changes, and eligible public page views. When the optional User Profiler is enabled by a site administrator, SHIELD-X sets a first-party shieldx_visitor cookie and stores recent request metadata for security review, including visitor identifier, IP address, informational country header when available, browser label, user agent, URL path, redacted query string, referrer with its query string redacted, admin-surface flag, user ID when logged in, and visit time. Anonymous front-end GET requests are sampled; authenticated front-end requests and non-GET requests are recorded at the full rate with their WordPress user ID when available. Automated user agents are not filtered. User Profiler data is retained locally for 30 days and is not sent to SHIELD-X or any third party by the plugin.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'The optional Vulnerability Scanner can contact configured advisory feeds, such as WPVulnerability or a custom feed URL selected by the site administrator, to compare installed WordPress, plugin, and theme versions against known advisories. These requests can include the installed WordPress version, plugin slugs, theme slugs, or detected runtime component versions, depending on the selected source. Advisory data is cached locally and no executable code is loaded from these feeds.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'The Core and Plugin Compare features download official WordPress core releases and WordPress.org plugin packages only when an administrator requests a refresh or comparison. These packages are cached locally to build clean file hashes for malware false-positive reduction.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'If an administrator enters a SHIELD-X activation code, checks license status, or deactivates the site license, SHIELD-X sends the activation code and this site instance URL to the Xerotact license service. When a license key is stored locally, SHIELD-X checks license status on admin page loads and WP-CLI license checks using a per-session cache and a short backoff after remote errors. Visitor logs, scanner findings, backup contents, recovery tokens, and firewall events are not sent to the license service by the plugin.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'The optional reputation feed feature downloads administrator-configured JSON from a public HTTPS URL and caches IP, hostname, and user-agent reputation entries locally. Reputation allowlists are configured locally by the administrator to reduce false positives.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'The optional Human Challenge module uses Cap.js Challenge (Captcha) when configured by the site administrator. SHIELD-X includes a local WordPress Cap.js-compatible challenge endpoint by default and can also use an administrator-configured endpoint when enabled for login, password reset, or selected firewall rules. SHIELD-X sends the visitor challenge token to the configured endpoint /siteverify URL with the saved site secret so the Cap.js Challenge (Captcha) service can confirm the proof-of-work challenge. Challenge features are disabled by default.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'When an administrator installs, connects, and explicitly confirms a SHIELD-X AI Gateway scanner review, SHIELD-X sends the complete selected flagged text file, its relative path, SHA-256 hash, size, and active scanner evidence to OpenRouter and the selected routed model provider for advisory analysis. The file is not redacted for this optional workflow, and no file is sent automatically during scans. SHIELD-X requests OpenRouter zero-data-retention routing and denies provider data collection for these requests. The OpenRouter API key is encrypted locally with site salts; SHIELD-X stores the structured review result and request metadata locally, not the transmitted file or prompt. AI review never performs remediation automatically.',
            'xerotact-shield-x-security'
        ) . '</p><p>' . esc_html__(
            'When the administrator enables Google Drive backup storage, SHIELD-X stores Google refresh tokens encrypted locally using the site salts and uploads backup files directly from this WordPress site to a SHIELD-X backup folder in the connected Google Drive account. Backup contents, Google tokens, and Google Drive file identifiers are not sent to SHIELD-X servers by the plugin.',
            'xerotact-shield-x-security'
        ) . '</p>';

        wp_add_privacy_policy_content('SHIELD-X', $content);
    }

    // phpcs:disable WordPress.Security.NonceVerification.Missing -- Recovery, external scan, external backup, and external restore-point endpoints use signed tokens, throttling, and 2FA or runner tokens instead of WordPress admin nonces.
    public function maybeRunEmergencyRecovery(): void
    {
        try {
            $this->maybeRunEmergencyRecoveryGuarded();
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write('SHIELD-X emergency recovery failed: ' . $error->getMessage());
            $this->renderNotFoundAndExit();
        }
    }

    private function maybeRunEmergencyRecoveryGuarded(): void
    {
        $uri = $this->serverText('REQUEST_URI');

        if (!self::isEmergencyRecoveryRequest($uri)) {
            return;
        }

        $token = $this->tokenFromRequest();
        $settings = $this->loginSecurity()->settings();
        $method = strtoupper($this->serverText('REQUEST_METHOD', 'GET'));

        if (!$settings->matchesEmergencyRecoveryToken($token)) {
            if ($method === 'GET' && $token === '') {
                $this->renderEmergencyRecoveryTokenEntry();
            }

            $this->renderNotFoundAndExit();
        }

        $identifier = $this->postedText('shieldx_admin_identifier');
        $ip = MinimalWaf::clientIpFromServer($_SERVER, $this->wafSettings());

        if ($method === 'POST' && $this->postedText('shieldx_recovery_step') === 'token') {
            $this->renderEmergencyRecoveryChallenge($token);
            exit;
        }

        if ($method !== 'POST') {
            $this->renderEmergencyRecoveryChallenge($token, '', $identifier);
            exit;
        }

        if ($this->postedText('shieldx_recovery_step') === 'apply') {
            $sessionToken = $this->postedText('shieldx_recovery_session');
            $session = $this->emergencyRecoverySession($sessionToken);

            if (empty($session)) {
                $this->renderEmergencyRecoveryChallenge($token, __('Recovery selection expired. Verify 2FA again.', 'xerotact-shield-x-security'), '', 403);
                exit;
            }

            if ((string) ($session['ip'] ?? '') !== $ip) {
                $this->deleteEmergencyRecoverySession($sessionToken);
                $this->renderEmergencyRecoveryChallenge($token, __('Recovery selection expired for this network. Verify 2FA again.', 'xerotact-shield-x-security'), '', 403);
                exit;
            }

            if (!$this->recoveryActionNonceValid($sessionToken)) {
                $this->renderEmergencyRecoveryOptions($token, $sessionToken, $session, __('Recovery action security check failed. Verify 2FA again.', 'xerotact-shield-x-security'), 403);
                exit;
            }

            $selection = $this->postedRecoveryActions($sessionToken);
            if (empty($selection)) {
                $this->renderEmergencyRecoveryOptions($token, $sessionToken, $session, __('Select at least one recovery action.', 'xerotact-shield-x-security'), 422);
                exit;
            }

            $result = $this->performEmergencyRecovery(
                (int) ($session['user_id'] ?? 0),
                $ip,
                (string) ($session['method'] ?? ''),
                $selection
            );
            $this->deleteEmergencyRecoverySession($sessionToken);
            $this->renderEmergencyRecoveryResult($result);
            exit;
        }

        try {
            $throttle = $this->emergencyRecoveryThrottle()->assess($token, $ip, $identifier);
        } catch (EmergencyRecoveryThrottleStorageUnavailableException $error) {
            $this->renderEmergencyRecoveryStorageUnavailable($token);
            exit;
        }
        if (!empty($throttle['locked']) && (string) ($throttle['reason'] ?? '') !== 'identifier') {
            $this->renderEmergencyRecoveryChallenge($token, $this->recoveryThrottleMessage((int) ($throttle['retry_after'] ?? 0)), $identifier, 429);
            exit;
        }

        $verification = $this->loginSecurity()->verifyEmergencyCodeForAdmin(
            $identifier,
            $this->postedText('shieldx_totp_code')
        );

        if (empty($verification['ok'])) {
            try {
                $throttle = $this->emergencyRecoveryThrottle()->recordFailure($token, $ip, $identifier);
            } catch (EmergencyRecoveryThrottleStorageUnavailableException $error) {
                $this->renderEmergencyRecoveryStorageUnavailable($token);
                exit;
            }
            if (!empty($throttle['locked'])) {
                $this->renderEmergencyRecoveryChallenge($token, $this->recoveryThrottleMessage((int) ($throttle['retry_after'] ?? 0)), $identifier, 429);
                exit;
            }
            $this->renderEmergencyRecoveryChallenge($token, (string) ($verification['error'] ?? ''), $identifier, 403);
            exit;
        }

        try {
            $this->emergencyRecoveryThrottle()->recordSuccess($token, $ip, $identifier);
        } catch (EmergencyRecoveryThrottleStorageUnavailableException $error) {
            $this->renderEmergencyRecoveryStorageUnavailable($token);
            exit;
        }
        $sessionToken = $this->createEmergencyRecoverySession((int) ($verification['user_id'] ?? 0), $ip, (string) ($verification['method'] ?? ''));
        $this->renderEmergencyRecoveryOptions($token, $sessionToken, $this->emergencyRecoverySession($sessionToken));
        exit;
    }

    public function maybeRunExternalScan(): void
    {
        $uri = $this->serverText('REQUEST_URI');
        $path = self::urlPath($uri);

        if (!ExternalRunnerToken::matchesEndpointPath($path, '/autoscan')) {
            return;
        }

        if (!SchedulerBridge::active()) {
            $this->renderNotFoundAndExit();
        }

        $token = $this->externalScanTokenFromRequest();

        $scanType = $this->scanSettings()->typeFromExternalToken($token);

        if ($scanType === '' || !$this->scanSettings()->externalRunnerEnabled($scanType)) {
            $this->renderNotFoundAndExit();
        }

        $throttle = $this->autoscanThrottle($token, $scanType);
        if (!empty($throttle['limited'])) {
            $payload = [
                'ok' => false,
                'status' => 'throttled',
                'scan_type' => $scanType,
                'retry_after' => (int) ($throttle['retry_after'] ?? self::AUTOSCAN_THROTTLE_SECONDS),
            ];

            self::sendJsonResponse($payload, 429, (int) $payload['retry_after']);
        }

        $result = $this->scheduledScanRunner()->run(true, $scanType);
        $payload = [
            'ok' => ($result['status'] ?? '') === 'completed',
            'status' => (string) ($result['status'] ?? 'unknown'),
            'scan_type' => $scanType,
            'reason' => (string) ($result['reason'] ?? ''),
            'scan_id' => (string) ($result['scan_id'] ?? ''),
            'files_total' => (int) ($result['files_total'] ?? 0),
            'files_scanned' => (int) ($result['files_scanned'] ?? 0),
            'files_skipped' => (int) ($result['files_skipped'] ?? 0),
            'issues_found' => (int) ($result['issues_found'] ?? 0),
            'duration_ms' => (int) ($result['duration_ms'] ?? 0),
        ];

        if (!empty($result['error'])) {
            $payload['error'] = (string) $result['error'];
        }

        self::sendJsonResponse($payload, ($result['status'] ?? '') === 'failed' ? 500 : 200);
    }

    public function maybeRunExternalBackup(): void
    {
        $uri = $this->serverText('REQUEST_URI');
        $path = self::urlPath($uri);

        if (!ExternalRunnerToken::matchesEndpointPath($path, '/autobackup')) {
            return;
        }

        if (!SchedulerBridge::active()) {
            $this->renderNotFoundAndExit();
        }

        $token = $this->externalScanTokenFromRequest();

        if (!$this->backupSettings()->matchesExternalToken($token) || !$this->backupSettings()->externalRunnerEnabled()) {
            $this->renderNotFoundAndExit();
        }

        $throttle = $this->autoscanThrottle($token, 'backup');
        if (!empty($throttle['limited'])) {
            $payload = [
                'ok' => false,
                'status' => 'throttled',
                'retry_after' => (int) ($throttle['retry_after'] ?? self::AUTOSCAN_THROTTLE_SECONDS),
            ];

            self::sendJsonResponse($payload, 429, (int) $payload['retry_after']);
        }

        $result = $this->scheduledBackupRunner()->run(true);
        $payload = [
            'ok' => ($result['status'] ?? '') === 'completed',
            'status' => (string) ($result['status'] ?? 'unknown'),
            'reason' => (string) ($result['reason'] ?? ''),
            'backup_id' => (string) ($result['backup_id'] ?? ''),
            'tables' => (int) ($result['tables'] ?? 0),
            'rows_exported' => (int) ($result['rows_exported'] ?? 0),
            'size' => (int) ($result['size'] ?? 0),
            'warning_count' => (int) ($result['warning_count'] ?? 0),
            'duration_ms' => (int) ($result['duration_ms'] ?? 0),
        ];

        if (!empty($result['error'])) {
            $payload['error'] = (string) $result['error'];
        }

        self::sendJsonResponse($payload, ($result['status'] ?? '') === 'failed' ? 500 : 200);
    }

    public function maybeRunExternalRestorePoint(): void
    {
        $uri = $this->serverText('REQUEST_URI');
        $path = self::urlPath($uri);

        if (!ExternalRunnerToken::matchesEndpointPath($path, '/autorestorepoint')) {
            return;
        }

        if (!SchedulerBridge::active()) {
            $this->renderNotFoundAndExit();
        }

        $token = $this->externalScanTokenFromRequest();

        if (!$this->restorePointScheduleSettings()->matchesExternalToken($token) || !$this->restorePointScheduleSettings()->externalRunnerEnabled()) {
            $this->renderNotFoundAndExit();
        }

        $throttle = $this->autoscanThrottle($token, 'restore_point');
        if (!empty($throttle['limited'])) {
            $payload = [
                'ok' => false,
                'status' => 'throttled',
                'retry_after' => (int) ($throttle['retry_after'] ?? self::AUTOSCAN_THROTTLE_SECONDS),
            ];

            self::sendJsonResponse($payload, 429, (int) $payload['retry_after']);
        }

        $result = $this->scheduledRestorePointRunner()->run(true);
        $payload = [
            'ok' => ($result['status'] ?? '') === 'completed',
            'status' => (string) ($result['status'] ?? 'unknown'),
            'reason' => (string) ($result['reason'] ?? ''),
            'restore_id' => (string) ($result['restore_id'] ?? ''),
            'files' => (int) ($result['files'] ?? 0),
            'bytes_exported' => (int) ($result['bytes_exported'] ?? 0),
            'database_backup_id' => (string) ($result['database_backup_id'] ?? ''),
            'truncated' => !empty($result['truncated']),
            'warning_count' => (int) ($result['warning_count'] ?? 0),
            'duration_ms' => (int) ($result['duration_ms'] ?? 0),
        ];

        if (!empty($result['error'])) {
            $payload['error'] = (string) $result['error'];
        }

        self::sendJsonResponse($payload, ($result['status'] ?? '') === 'failed' ? 500 : 200);
    }
    // phpcs:enable WordPress.Security.NonceVerification.Missing

    /**
     * @return array{limited:bool,retry_after:int}
     */
    private function autoscanThrottle(string $token, string $scanType): array
    {
        unset($scanType);
        // A bearer credential is globally throttled, regardless of source IP.
        // This prevents an exposed token from evading limits by rotating IPs.
        $key = 'shieldx_external_runner_' . hash('sha256', $token);
        $now = time();

        if (function_exists('get_transient') && function_exists('set_transient')) {
            $until = (int) get_transient($key);
            if ($until > $now) {
                return [
                    'limited' => true,
                    'retry_after' => max(1, $until - $now),
                ];
            }

            set_transient($key, $now + self::AUTOSCAN_THROTTLE_SECONDS, self::AUTOSCAN_THROTTLE_SECONDS);
        }

        return [
            'limited' => false,
            'retry_after' => 0,
        ];
    }

    /**
     * @param array<string,mixed> $payload
     */
    private static function sendJsonResponse(array $payload, int $statusCode, int $retryAfter = 0): void
    {
        if ($retryAfter > 0 && !headers_sent()) {
            header('Retry-After: ' . (string) $retryAfter);
        }

        wp_send_json($payload, $statusCode);
    }

    private function renderNotFoundAndExit(): void
    {
        if (function_exists('status_header')) {
            status_header(404);
        } else {
            http_response_code(404);
        }

        echo esc_html__('Not found', 'xerotact-shield-x-security');
        exit;
    }

    public function runScheduledScan(string $type = ScheduledScanSettings::TYPE_FILE): void
    {
        $this->scheduledScanRunner()->run(false, $type);
    }

    public function runScheduledBackup(): void
    {
        $this->scheduledBackupRunner()->run(false);
    }

    public function runScheduledRestorePoint(string $continuation = ''): void
    {
        unset($continuation);

        $this->scheduledRestorePointRunner()->run(false);
    }

    public function runScheduledTempCleanup(): void
    {
        try {
            LogRotator::fromWordPress()->cleanupTemporaryFiles(false);
        } catch (\Throwable $error) {
            $this->logger()->warning('maintenance.temp_cleanup_failed', [
                'error' => $this->safeExceptionMessage($error),
            ]);
        }
    }

    /**
     * Keeps the fallback cron set healthy without re-clearing or re-scheduling
     * it on every ordinary request when the Scheduler module is inactive.
     */
    public function maybeReconcileCoreSchedules(): void
    {
        if (SchedulerBridge::active() || $this->coreScheduleReconcileRecentlyRan()) {
            return;
        }

        $this->clearCoreSchedulesWhenSchedulerInactive();
        Activator::scheduleCoreFallbacks();
        $this->recordCoreScheduleReconcile();
    }

    public function clearCoreSchedulesWhenSchedulerInactive(): void
    {
        if (SchedulerBridge::active()) {
            return;
        }

        ReputationFeed::clearScheduled();
        if (function_exists('wp_clear_scheduled_hook')) {
            wp_clear_scheduled_hook(ScheduledScanRunner::HOOK, []);
            foreach (ScheduledScanSettings::types() as $type) {
                wp_clear_scheduled_hook(ScheduledScanRunner::HOOK, [$type]);
            }
            wp_clear_scheduled_hook(ScheduledBackupRunner::HOOK, []);
            wp_clear_scheduled_hook(ScheduledRestorePointRunner::HOOK, []);
        }
    }

    private function coreScheduleReconcileRecentlyRan(): bool
    {
        return function_exists('get_transient') && (bool) get_transient(self::SCHEDULER_RECONCILE_TRANSIENT);
    }

    private function recordCoreScheduleReconcile(): void
    {
        if (function_exists('set_transient')) {
            set_transient(self::SCHEDULER_RECONCILE_TRANSIENT, '1', self::SCHEDULER_RECONCILE_TTL_SECONDS);
        }
    }

    /**
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,array<string,mixed>>
     */
    public function hydrateSchedulerTaskDisplay(array $tasks): array
    {
        $maintenance = $this->lastMaintenanceResult();
        $temporary = function_exists('get_option') ? get_option('shieldx_temp_cleanup_last_result', []) : [];
        $temporary = is_array($temporary) ? $temporary : [];

        foreach ($tasks as $index => $task) {
            if ((string) ($task['id'] ?? '') === 'core.cleanup.maintenance') {
                $tasks[$index]['last_run_at'] = (string) ($maintenance['ran_at'] ?? '');
                $tasks[$index]['last_status'] = (string) ($maintenance['status'] ?? '');
            }

            if ((string) ($task['id'] ?? '') === 'core.cleanup.temporary_files') {
                $tasks[$index]['last_run_at'] = (string) ($temporary['ran_at'] ?? '');
                $tasks[$index]['last_status'] = (string) ($temporary['status'] ?? '');
            }
        }

        return $tasks;
    }

    /**
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,array<string,mixed>>
     */
    public function registerSchedulerTasks(array $tasks): array
    {
        foreach (ScheduledScanSettings::types() as $type) {
            $section = $this->scanSettings()->section($type);
            $tasks[] = [
                'id' => 'core.scan.' . $type,
                'module' => 'Malware/Virus',
                'label' => $this->schedulerScanLabel($type),
                'description' => $this->schedulerScanDescription($type),
                'hook' => ScheduledScanRunner::HOOK,
                'args' => [$type],
                'enabled' => !empty($section['enabled']),
                'recurrence' => (string) ($section['recurrence'] ?? 'daily'),
                'min_custom_minutes' => $type === ScheduledScanSettings::TYPE_VULNERABILITY ? 120 : 60,
                'custom_interval_minutes' => 60,
                'last_run_at' => (string) ($section['last_run_at'] ?? ''),
                'last_status' => (string) ($section['last_status'] ?? ''),
                'save_callback' => [$this, 'saveSchedulerTask'],
                'run_callback' => [$this, 'runSchedulerTask'],
                'clear_callback' => [$this, 'clearSchedulerTask'],
            ];
        }

        $backup = $this->backupSettings()->all();
        $tasks[] = [
            'id' => 'core.backup.database',
            'module' => 'Backup',
            'label' => 'Database backup',
            'description' => 'Exports WordPress database tables to SHIELD-X private storage.',
            'hook' => ScheduledBackupRunner::HOOK,
            'args' => [],
            'enabled' => !empty($backup['enabled']),
            'recurrence' => (string) ($backup['recurrence'] ?? 'daily'),
            'min_custom_minutes' => 120,
            'custom_interval_minutes' => 120,
            'last_run_at' => (string) ($backup['last_run_at'] ?? ''),
            'last_status' => (string) ($backup['last_status'] ?? ''),
            'save_callback' => [$this, 'saveSchedulerTask'],
            'run_callback' => [$this, 'runSchedulerTask'],
            'clear_callback' => [$this, 'clearSchedulerTask'],
        ];

        $restore = $this->restorePointScheduleSettings()->all();
        $tasks[] = [
            'id' => 'core.backup.restore_point',
            'module' => 'Backup',
            'label' => 'Restore point',
            'description' => 'Creates a file restore point and optional database backup.',
            'hook' => ScheduledRestorePointRunner::HOOK,
            'args' => [],
            'enabled' => !empty($restore['enabled']),
            'recurrence' => (string) ($restore['recurrence'] ?? 'daily'),
            'min_custom_minutes' => 120,
            'custom_interval_minutes' => 120,
            'last_run_at' => (string) ($restore['last_run_at'] ?? ''),
            'last_status' => (string) ($restore['last_status'] ?? ''),
            'save_callback' => [$this, 'saveSchedulerTask'],
            'run_callback' => [$this, 'runSchedulerTask'],
            'clear_callback' => [$this, 'clearSchedulerTask'],
        ];

        $waf = $this->wafSettings()->all();
        $reputationLast = (int) ($waf['reputation_last_refresh'] ?? 0);
        $reputationAttempt = max($reputationLast, (int) ($waf['reputation_last_attempt'] ?? 0));
        $reputationStatus = (string) ($waf['reputation_last_status'] ?? '');
        if ($reputationStatus === '' && (string) ($waf['reputation_last_error'] ?? '') !== '') {
            $reputationStatus = 'failed';
        } elseif ($reputationStatus === '' && $reputationLast > 0) {
            $reputationStatus = 'completed';
        }
        $tasks[] = [
            'id' => 'core.waf.reputation_feed',
            'module' => 'Firewall/WAF',
            'label' => 'Reputation feed refresh',
            'description' => 'Refreshes the configured HTTPS reputation feed.',
            'hook' => ReputationFeed::HOOK,
            'args' => [],
            'enabled' => (string) ($waf['reputation_feed_url'] ?? '') !== '',
            'recurrence' => 'daily',
            'min_custom_minutes' => 240,
            'custom_interval_minutes' => 240,
            'last_run_at' => $reputationAttempt > 0 ? gmdate('Y-m-d H:i:s \U\T\C', $reputationAttempt) : '',
            'last_status' => $reputationStatus,
            'run_callback' => [$this, 'runSchedulerTask'],
            'clear_callback' => [$this, 'clearSchedulerTask'],
        ];

        $tasks[] = [
            'id' => 'core.cleanup.maintenance',
            'module' => 'System',
            'label' => 'System health / log rotation',
            'description' => 'Runs storage checks, license validation, compatibility checks, database snapshots, and log rotation.',
            'hook' => 'shieldx_hourly_maintenance',
            'args' => [],
            'enabled' => true,
            'recurrence' => 'hourly',
            'min_custom_minutes' => 60,
            'custom_interval_minutes' => 60,
            'run_callback' => [$this, 'runSchedulerTask'],
            'clear_callback' => [$this, 'clearSchedulerTask'],
        ];

        $tasks[] = [
            'id' => 'core.cleanup.temporary_files',
            'module' => 'Cleanup',
            'label' => 'Temporary cleanup',
            'description' => 'Removes stale whole-site temporary artifacts with SHIELD-X safety exclusions.',
            'hook' => 'shieldx_temp_cleanup',
            'args' => [],
            'enabled' => false,
            'recurrence' => 'daily',
            'min_custom_minutes' => 240,
            'custom_interval_minutes' => 240,
            'run_callback' => [$this, 'runSchedulerTask'],
            'clear_callback' => [$this, 'clearSchedulerTask'],
        ];

        return $tasks;
    }

    /**
     * @param array<string,mixed> $settings
     */
    public function saveSchedulerTask(string $taskId, array $settings): void
    {
        $enabled = !empty($settings['enabled']);
        $recurrence = $this->schedulerStandardRecurrence((string) ($settings['recurrence'] ?? ''));

        if (strpos($taskId, 'core.scan.') === 0) {
            $type = ScheduledScanSettings::sanitizeType(substr($taskId, strlen('core.scan.')));
            $current = $this->scanSettings()->all();
            $sections = isset($current['sections']) && is_array($current['sections']) ? $current['sections'] : [];
            $section = isset($sections[$type]) && is_array($sections[$type]) ? $sections[$type] : $this->scanSettings()->section($type);
            $section['enabled'] = $enabled;
            if ($recurrence !== '') {
                $section['recurrence'] = $recurrence;
            }
            $sections[$type] = $section;
            $this->scanSettings()->save(['sections' => $sections]);
            return;
        }

        if ($taskId === 'core.backup.database') {
            $next = ['enabled' => $enabled];
            if ($recurrence !== '') {
                $next['recurrence'] = $recurrence;
            }
            $this->backupSettings()->save($next);
            return;
        }

        if ($taskId === 'core.backup.restore_point') {
            $next = ['enabled' => $enabled];
            if ($recurrence !== '') {
                $next['recurrence'] = $recurrence;
            }
            $this->restorePointScheduleSettings()->save($next);
        }
    }

    public function runSchedulerTask(string $taskId): void
    {
        if (strpos($taskId, 'core.scan.') === 0) {
            $type = ScheduledScanSettings::sanitizeType(substr($taskId, strlen('core.scan.')));
            $this->scheduledScanRunner()->run(true, $type);
            return;
        }

        if ($taskId === 'core.backup.database') {
            $this->scheduledBackupRunner()->run(true);
            return;
        }

        if ($taskId === 'core.backup.restore_point') {
            $this->scheduledRestorePointRunner()->run(true);
            return;
        }

        if ($taskId === 'core.waf.reputation_feed') {
            ReputationFeed::runScheduled();
            return;
        }

        if ($taskId === 'core.cleanup.maintenance') {
            $this->maintenance();
            return;
        }

        if ($taskId === 'core.cleanup.temporary_files') {
            $this->runScheduledTempCleanup();
        }
    }

    public function clearSchedulerTask(string $taskId): void
    {
        if (strpos($taskId, 'core.scan.') === 0) {
            ScheduledScanRunner::clearScheduled();
            return;
        }

        if ($taskId === 'core.backup.database') {
            ScheduledBackupRunner::clearScheduled();
            return;
        }

        if ($taskId === 'core.backup.restore_point') {
            ScheduledRestorePointRunner::clearScheduled();
            return;
        }

        if ($taskId === 'core.waf.reputation_feed') {
            ReputationFeed::clearScheduled();
        }
    }

    private function schedulerScanLabel(string $type): string
    {
        if ($type === ScheduledScanSettings::TYPE_DATABASE) {
            return 'Database scan';
        }

        if ($type === ScheduledScanSettings::TYPE_VULNERABILITY) {
            return 'Vulnerability scan';
        }

        return 'File scan';
    }

    private function schedulerScanDescription(string $type): string
    {
        if ($type === ScheduledScanSettings::TYPE_DATABASE) {
            return 'Checks options, posts, widgets, cron hooks, redirects, and stored scripts.';
        }

        if ($type === ScheduledScanSettings::TYPE_VULNERABILITY) {
            return 'Checks core, plugins, themes, runtime software, and advisory feeds.';
        }

        return 'Runs smart modified-time, hash, and malware signature checks.';
    }

    private function schedulerStandardRecurrence(string $recurrence): string
    {
        return in_array($recurrence, ['hourly', 'twicedaily', 'daily', 'weekly'], true) ? $recurrence : '';
    }

    private static function isAdminOrCli(): bool
    {
        return (defined('WP_CLI') && WP_CLI) || (function_exists('is_admin') && is_admin());
    }

    private function currentShieldXAdminPage(): string
    {
        if (function_exists('is_admin') && !is_admin()) {
            return '';
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check for current admin page.
        $page = isset($_GET['page']) && is_scalar($_GET['page']) ? (string) sanitize_key(wp_unslash($_GET['page'])) : '';

        return $page === 'shield-x' || strpos($page, 'shield-x-') === 0 ? $page : '';
    }

    private function safeExceptionMessage(\Throwable $error): string
    {
        $message = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $error->getMessage());
        $message = is_string($message) ? trim($message) : '';
        if (function_exists('sanitize_text_field')) {
            $message = sanitize_text_field($message);
        } elseif (function_exists('wp_strip_all_tags')) {
            $message = trim(wp_strip_all_tags($message));
        } else {
            $message = trim((string) preg_replace('/<[^>]*>/', '', $message));
        }

        return substr($message, 0, 220);
    }

    private function storage(): FileStorage
    {
        if (!$this->storage instanceof FileStorage) {
            $this->storage = FileStorage::fromWordPress();
        }

        return $this->storage;
    }

    private function environment(): EnvironmentDetector
    {
        if (!$this->environment instanceof EnvironmentDetector) {
            $this->environment = EnvironmentDetector::fromWordPress();
        }

        return $this->environment;
    }

    private function logger(): AuditLogger
    {
        if (!$this->logger instanceof AuditLogger) {
            $this->logger = new AuditLogger($this->storage());
        }

        return $this->logger;
    }

    private function wafSettings(): WafSettings
    {
        if (!$this->wafSettings instanceof WafSettings) {
            $this->wafSettings = WafSettings::fromWordPress();
        }

        return $this->wafSettings;
    }

    private function hardeningSettings(): HardeningSettings
    {
        if (!$this->hardeningSettings instanceof HardeningSettings) {
            $this->hardeningSettings = HardeningSettings::fromWordPress();
        }

        return $this->hardeningSettings;
    }

    private function lockdownSettings(): LockdownSettings
    {
        if (!$this->lockdownSettings instanceof LockdownSettings) {
            $this->lockdownSettings = LockdownSettings::fromWordPress();
        }

        return $this->lockdownSettings;
    }

    private function rateLimitSettings(): RateLimitSettings
    {
        if (!$this->rateLimitSettings instanceof RateLimitSettings) {
            $this->rateLimitSettings = RateLimitSettings::fromWordPress();
        }

        return $this->rateLimitSettings;
    }

    private function rateLimiter(): RateLimiter
    {
        if (!$this->rateLimiter instanceof RateLimiter) {
            $this->rateLimiter = new RateLimiter($this->storage(), $this->rateLimitSettings(), $this->logger());
        }

        return $this->rateLimiter;
    }

    private function waf(): MinimalWaf
    {
        if (!$this->waf instanceof MinimalWaf) {
            $rules = array_merge(MinimalWaf::defaultRules(), (new VirtualPatchRepository())->activeRules());
            $this->waf = new MinimalWaf($rules, $this->wafSettings());
        }

        return $this->waf;
    }

    private function loginSecurity(): LoginSecurity
    {
        if (!$this->loginSecurity instanceof LoginSecurity) {
            $this->loginSecurity = LoginSecurity::fromWordPress($this->logger());
        }

        return $this->loginSecurity;
    }

    private function humanChallenge(): HumanChallenge
    {
        if (!$this->humanChallenge instanceof HumanChallenge) {
            $this->humanChallenge = HumanChallenge::fromWordPress($this->logger());
        }

        return $this->humanChallenge;
    }

    private function emergencyRecoveryThrottle(): EmergencyRecoveryThrottle
    {
        if (!$this->emergencyRecoveryThrottle instanceof EmergencyRecoveryThrottle) {
            $this->emergencyRecoveryThrottle = new EmergencyRecoveryThrottle();
        }

        return $this->emergencyRecoveryThrottle;
    }

    private function scanner(): LocalScanner
    {
        if (!$this->scanner instanceof LocalScanner) {
            $this->scanner = LocalScanner::fromWordPress();
        }

        return $this->scanner;
    }

    private function scanSettings(): ScheduledScanSettings
    {
        if (!$this->scanSettings instanceof ScheduledScanSettings) {
            $this->scanSettings = ScheduledScanSettings::fromWordPress();
        }

        return $this->scanSettings;
    }

    private function backupSettings(): ScheduledBackupSettings
    {
        if (!$this->backupSettings instanceof ScheduledBackupSettings) {
            $this->backupSettings = ScheduledBackupSettings::fromWordPress();
        }

        return $this->backupSettings;
    }

    private function restorePointScheduleSettings(): ScheduledRestorePointSettings
    {
        if (!$this->restorePointScheduleSettings instanceof ScheduledRestorePointSettings) {
            $this->restorePointScheduleSettings = ScheduledRestorePointSettings::fromWordPress();
        }

        return $this->restorePointScheduleSettings;
    }

    private function wafStats(): WafStats
    {
        if (!$this->wafStats instanceof WafStats) {
            $this->wafStats = new WafStats($this->storage());
        }

        return $this->wafStats;
    }

    private function userProfiler(): UserProfiler
    {
        if (!$this->userProfiler instanceof UserProfiler) {
            $this->userProfiler = UserProfiler::fromWordPress();
        }

        return $this->userProfiler;
    }

    private function updateCoordinator(): UpdateCoordinator
    {
        if (!$this->updateCoordinator instanceof UpdateCoordinator) {
            $this->updateCoordinator = UpdateCoordinator::fromWordPress($this->lockdown(), $this->logger());
        }

        return $this->updateCoordinator;
    }

    private function licenseManager(): LicenseManager
    {
        if (!$this->licenseManager instanceof LicenseManager) {
            $this->licenseManager = LicenseManager::fromWordPress();
        }

        return $this->licenseManager;
    }

    private function scheduledScanRunner(): ScheduledScanRunner
    {
        if (!$this->scheduledScanRunner instanceof ScheduledScanRunner) {
            $this->scheduledScanRunner = new ScheduledScanRunner($this->scanner(), $this->scanSettings(), $this->logger());
        }

        return $this->scheduledScanRunner;
    }

    private function scheduledBackupRunner(): ScheduledBackupRunner
    {
        if (!$this->scheduledBackupRunner instanceof ScheduledBackupRunner) {
            $this->scheduledBackupRunner = new ScheduledBackupRunner(DatabaseBackupManager::fromWordPress(), $this->backupSettings(), $this->logger());
        }

        return $this->scheduledBackupRunner;
    }

    private function scheduledRestorePointRunner(): ScheduledRestorePointRunner
    {
        if (!$this->scheduledRestorePointRunner instanceof ScheduledRestorePointRunner) {
            $this->scheduledRestorePointRunner = new ScheduledRestorePointRunner(RestorePointManager::fromWordPress(), $this->restorePointScheduleSettings(), $this->logger());
        }

        return $this->scheduledRestorePointRunner;
    }

    public function maintenance(): void
    {
        $ranAt = gmdate('Y-m-d H:i:s \U\T\C');

        try {
            $this->storage()->ensure();
            $this->licenseManager()->validateIfDue();
            $this->notifyCompatibilityWarning($this->environment()->detect());
            $this->recordDatabaseTableSnapshotChanges('maintenance');
            $rotation = LogRotator::fromWordPress()->rotate(false);
            $rotationStatus = (string) ($rotation['status'] ?? '');
            $status = $rotationStatus === 'failed'
                ? 'failed'
                : ($rotationStatus === 'completed_with_warnings' ? 'completed_with_warnings' : 'completed');

            $result = [
                'status' => $status,
                'ran_at' => $ranAt,
                'log_rotation_status' => $rotationStatus,
                'log_rotation_deleted_files' => (int) ($rotation['deleted_files'] ?? 0),
                'log_rotation_deleted_bytes' => (int) ($rotation['deleted_bytes'] ?? 0),
            ];
            $this->recordMaintenanceResult($result);

            $this->logger()->info('maintenance.tick', [
                'version' => SHIELDX_VERSION,
                'log_rotation_status' => $rotationStatus,
                'log_rotation_deleted_files' => (int) ($rotation['deleted_files'] ?? 0),
                'log_rotation_deleted_bytes' => (int) ($rotation['deleted_bytes'] ?? 0),
            ]);

        } catch (\Throwable $error) {
            $result = [
                'status' => 'failed',
                'ran_at' => $ranAt,
                'error' => $error->getMessage(),
            ];
            $this->recordMaintenanceResult($result);
            $this->logger()->warning('maintenance.tick_failed', [
                'error' => $this->safeExceptionMessage($error),
            ]);
        }
    }

    public function validateCurrentAdminSession(): void
    {
        $page = $this->currentShieldXAdminPage();
        if ($page === '') {
            return;
        }

        $capability = defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';
        if (function_exists('current_user_can') && !current_user_can($capability)) {
            return;
        }

        try {
            $this->licenseManager()->validateForCurrentSession();
        } catch (\Throwable $error) {
            $this->logger()->warning('license.session_validation_failed', [
                'message' => $this->safeExceptionMessage($error),
            ]);
        }

        if (function_exists('do_action')) {
            try {
                do_action('shieldx_admin_session_check', [
                    'page' => $page,
                ]);
            } catch (\Throwable $error) {
                $this->logger()->warning('admin.session_check_failed', [
                    'message' => $this->safeExceptionMessage($error),
                ]);
            }
        }
    }

    /**
     * @return array<string,mixed>
     */
    private function lastMaintenanceResult(): array
    {
        $result = function_exists('get_option') ? get_option(self::MAINTENANCE_LAST_RESULT_OPTION, []) : [];

        return is_array($result) ? $result : [];
    }

    /**
     * @param array<string,mixed> $result
     */
    private function recordMaintenanceResult(array $result): void
    {
        if (function_exists('update_option')) {
            try {
                update_option(self::MAINTENANCE_LAST_RESULT_OPTION, $result, false);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X maintenance result write failed: ' . $error->getMessage());
            }
        }
    }

    /**
     * @param array<string,mixed> $environment
     */
    private function notifyCompatibilityWarning(array $environment): void
    {
        $compatibility = isset($environment['compatibility']) && is_array($environment['compatibility'])
            ? $environment['compatibility']
            : [];
        $summary = isset($compatibility['summary']) && is_array($compatibility['summary'])
            ? $compatibility['summary']
            : [];
        $status = (string) ($summary['status'] ?? 'ready');
        if (!in_array($status, ['warning', 'missing'], true)) {
            return;
        }

        NotificationManager::fromWordPress()->notify('system.compatibility_warning', [
            'operation' => 'compatibility_check',
            'status' => $status,
            'missing' => (int) ($summary['missing'] ?? 0),
            'warning' => (int) ($summary['warning'] ?? 0),
            'module_missing' => (int) ($summary['module_missing'] ?? 0),
            'module_warning' => (int) ($summary['module_warning'] ?? 0),
        ], 'warning');
    }

    /**
     * @param mixed $user
     * @return mixed
     */
    public function checkLoginLockout($user, string $username, string $password)
    {
        unset($password);

        if (!$this->rateLimitSettings()->loginProtectionEnabled() || trim($username) === '') {
            return $user;
        }

        $ip = MinimalWaf::clientIpFromServer($_SERVER, $this->wafSettings());
        $status = $this->rateLimiter()->loginLockoutStatus($ip, $username);

        if (empty($status['storage_ready'])) {
            $context = [
                'ip' => $ip,
                'username' => $username,
            ];
            $this->logger()->warning('login.lockout_storage_unavailable', $context);

            if (class_exists('\WP_Error')) {
                return new \WP_Error(
                    'shieldx_login_protection_unavailable',
                    __('Login protection is temporarily unavailable. Please try again shortly.', 'xerotact-shield-x-security')
                );
            }

            return $user;
        }

        if (empty($status['locked'])) {
            return $user;
        }

        $context = [
            'ip' => $ip,
            'username' => $username,
            'retry_after' => (int) ($status['retry_after'] ?? 0),
            'failures' => (int) ($status['failures'] ?? 0),
            'limit' => (int) ($status['limit'] ?? 0),
        ];
        $this->logger()->warning('login.lockout_block', $context);

        if (class_exists('\WP_Error')) {
            return new \WP_Error(
                'shieldx_login_locked',
                sprintf(
                    /* translators: %d: Retry wait time in seconds. */
                    __('Too many failed login attempts. Try again in %d seconds.', 'xerotact-shield-x-security'),
                    (int) ($status['retry_after'] ?? 0)
                )
            );
        }

        return $user;
    }

    /**
     * @param mixed $username
     */
    public function recordLoginFailure($username): void
    {
        if (!$this->rateLimitSettings()->loginProtectionEnabled() || !is_scalar($username)) {
            return;
        }

        $dedupeKey = $this->loginFailureDedupeKey((string) $username);
        if (($this->loginFailureDedupe[$dedupeKey] ?? 0) > 0) {
            $this->loginFailureDedupe[$dedupeKey]--;
            if ($this->loginFailureDedupe[$dedupeKey] <= 0) {
                unset($this->loginFailureDedupe[$dedupeKey]);
            }

            return;
        }

        $this->recordLoginFailureNow((string) $username);
    }

    private function recordLoginFailureNow(string $username): void
    {
        $ip = MinimalWaf::clientIpFromServer($_SERVER, $this->wafSettings());
        $result = $this->rateLimiter()->recordLoginFailure($ip, $username);
        $context = [
            'ip' => $ip,
            'username' => $username,
            'failures' => (int) ($result['failures'] ?? 0),
            'limit' => (int) ($result['limit'] ?? 0),
            'locked' => !empty($result['locked']),
            'retry_after' => (int) ($result['retry_after'] ?? 0),
        ];

        if (!empty($result['new_lockout'])) {
            $this->logger()->warning('login.lockout_created', $context);
            return;
        }

        $this->logger()->info('login.failure', $context);
    }

    /**
     * @param mixed $user
     * @param mixed $username
     * @param mixed $password
     * @return mixed
     */
    public function recordAuthenticationFailure($user, $username, $password)
    {
        unset($password);

        if (
            !$this->rateLimitSettings()->loginProtectionEnabled()
            || !class_exists('\WP_Error')
            || !$user instanceof \WP_Error
            || !is_scalar($username)
        ) {
            return $user;
        }

        $username = trim((string) $username);
        if ($username === '' || !$this->isLoginFailureError($user)) {
            return $user;
        }

        $this->recordLoginFailureNow($username);
        $dedupeKey = $this->loginFailureDedupeKey($username);
        $this->loginFailureDedupe[$dedupeKey] = ($this->loginFailureDedupe[$dedupeKey] ?? 0) + 1;

        return $user;
    }

    private function isLoginFailureError(\WP_Error $error): bool
    {
        $codes = method_exists($error, 'get_error_codes')
            ? array_map('strval', $error->get_error_codes())
            : [method_exists($error, 'get_error_code') ? (string) $error->get_error_code() : ''];
        $countedCodes = [
            'incorrect_password',
            'invalid_email',
            'invalid_username',
            'shieldx_2fa_required',
            'shieldx_human_challenge_required',
        ];

        return !empty(array_intersect($codes, $countedCodes));
    }

    private function loginFailureDedupeKey(string $username): string
    {
        $username = function_exists('mb_strtolower') ? mb_strtolower(trim($username), 'UTF-8') : strtolower(trim($username));

        return $username !== '' ? $username : 'unknown';
    }

    /**
     * @param array<string,mixed> $request
     * @param array<string,mixed> $result
     * @return array<string,int|string>
     */
    private function syncWafRuleTemporaryBlock(array $request, array $result): array
    {
        $ip = (string) ($request['ip'] ?? '');
        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            return [];
        }

        $minutes = $this->wafSettings()->temporaryRuleBlockMinutes();
        $expiresAt = time() + ($minutes * 60);
        $rules = isset($result['blocking_rules']) && is_array($result['blocking_rules'])
            ? array_values(array_filter(array_map('strval', $result['blocking_rules'])))
            : [];
        if (empty($rules) && isset($result['rules']) && is_array($result['rules'])) {
            $rules = array_values(array_filter(array_map('strval', $result['rules'])));
        }

        $blockId = $this->wafRuleTemporaryBlockId($ip);
        $reason = sprintf(
            'Automatic WAF temporary block for %d minutes after rules: %s.',
            $minutes,
            !empty($rules) ? implode(', ', array_slice($rules, 0, 5)) : 'rule_match'
        );
        $settings = $this->wafSettings()->all();
        $blocks = isset($settings['temporary_blocks']) && is_array($settings['temporary_blocks']) ? $settings['temporary_blocks'] : [];
        $replaced = false;
        $storedExpiresAt = $expiresAt;

        foreach ($blocks as $index => $block) {
            if (!is_array($block) || (string) ($block['id'] ?? '') !== $blockId) {
                continue;
            }

            $existingExpiresAt = (int) ($block['expires_at'] ?? 0);
            if ((string) ($block['type'] ?? '') === 'ip' && (string) ($block['value'] ?? '') === $ip && $existingExpiresAt > time()) {
                return [
                    'temporary_id' => $blockId,
                    'expires_at' => $existingExpiresAt,
                ];
            }

            $storedExpiresAt = max($expiresAt, (int) ($block['expires_at'] ?? 0));
            $blocks[$index] = [
                'id' => $blockId,
                'type' => 'ip',
                'value' => $ip,
                'expires_at' => $storedExpiresAt,
                'reason' => $reason,
            ];
            $replaced = true;
            break;
        }

        if (!$replaced) {
            $blocks[] = [
                'id' => $blockId,
                'type' => 'ip',
                'value' => $ip,
                'expires_at' => $expiresAt,
                'reason' => $reason,
            ];
        }

        $settings['temporary_blocks'] = $blocks;
        $this->wafSettings()->save(
            isset($settings['disabled_rules']) && is_array($settings['disabled_rules']) ? $settings['disabled_rules'] : [],
            isset($settings['plugin_whitelist']) && is_array($settings['plugin_whitelist']) ? $settings['plugin_whitelist'] : [],
            $settings
        );
        $this->statusCache = null;

        return [
            'temporary_id' => $blockId,
            'expires_at' => $storedExpiresAt,
        ];
    }

    private function wafRuleTemporaryBlockId(string $ip): string
    {
        return substr(sha1('waf-rule|' . $ip), 0, 16);
    }

    /**
     * @param mixed $user
     */
    public function clearLoginFailures(string $userLogin, $user): void
    {
        if (!$this->rateLimitSettings()->loginProtectionEnabled()) {
            return;
        }

        $ip = MinimalWaf::clientIpFromServer($_SERVER, $this->wafSettings());
        $this->rateLimiter()->clearLoginFailures($ip, $userLogin);
    }

    /**
     * @param mixed $user
     */
    public function recordUserLogin(string $userLogin, $user): void
    {
        $context = $this->userAuditContext(0, $user, $userLogin);
        $context['ip'] = $this->auditClientIp();

        $this->logger()->info('activity.user_login', $context);
    }

    /**
     * @param mixed $userId
     */
    public function recordUserLogout($userId = 0): void
    {
        $id = is_scalar($userId) ? (int) $userId : 0;
        $user = null;

        if ($id > 0 && function_exists('get_userdata')) {
            $found = get_userdata($id);
            $user = is_object($found) ? $found : null;
        }

        $context = $this->userAuditContext($id, $user);
        $context['ip'] = $this->auditClientIp();

        $this->logger()->info('activity.user_logout', $context);
    }

    /**
     * @param mixed $post
     */
    public function recordPostStatusTransition(string $newStatus, string $oldStatus, $post): void
    {
        $newStatus = trim($newStatus);
        $oldStatus = trim($oldStatus);

        if ($newStatus === '' || $newStatus === $oldStatus || !$this->shouldAuditPostActivity($post)) {
            return;
        }

        if ($oldStatus === 'new' && $newStatus !== 'publish') {
            return;
        }

        if (in_array($newStatus, ['auto-draft', 'inherit'], true)) {
            return;
        }

        $context = $this->postAuditContext($post);
        $context['old_status'] = $oldStatus;
        $context['new_status'] = $newStatus;
        $event = $newStatus === 'publish' && $oldStatus !== 'publish'
            ? 'activity.post_published'
            : 'activity.post_status_changed';

        if ($event === 'activity.post_status_changed' && ($newStatus === 'trash' || ($oldStatus === 'publish' && $newStatus !== 'publish'))) {
            $this->logger()->warning($event, $context);
            return;
        }

        $this->logger()->info($event, $context);
    }

    /**
     * @param mixed $postAfter
     * @param mixed $postBefore
     */
    public function recordPostUpdated(int $postId, $postAfter, $postBefore): void
    {
        if (!$this->shouldAuditPostActivity($postAfter)) {
            return;
        }

        $afterStatus = is_object($postAfter) && isset($postAfter->post_status) ? (string) $postAfter->post_status : '';
        $beforeStatus = is_object($postBefore) && isset($postBefore->post_status) ? (string) $postBefore->post_status : '';
        if ($afterStatus !== $beforeStatus) {
            return;
        }

        $changedFields = $this->changedPostFields($postAfter, $postBefore);
        if (empty($changedFields)) {
            return;
        }

        $context = $this->postAuditContext($postAfter);
        if ((int) ($context['post_id'] ?? 0) <= 0) {
            $context['post_id'] = $postId;
        }
        $context['changed_fields'] = implode(', ', $changedFields);

        $this->logger()->info('activity.post_updated', $context);
    }

    /**
     * @param mixed $post
     */
    public function recordPostDeleted(int $postId, $post = null): void
    {
        if (!is_object($post) && function_exists('get_post')) {
            $found = get_post($postId);
            $post = is_object($found) ? $found : $post;
        }

        if (!$this->shouldAuditPostActivity($post)) {
            return;
        }

        $context = $this->postAuditContext($post);
        if ((int) ($context['post_id'] ?? 0) <= 0) {
            $context['post_id'] = $postId;
        }

        $this->logger()->warning('activity.post_deleted', $context);
    }

    /**
     * @param mixed $metaValue
     */
    public function recordPostMetaAdded(int $metaId, int $postId, string $metaKey, $metaValue = null): void
    {
        $this->recordPostMetaChange('activity.post_meta_added', $postId, $metaKey);
    }

    /**
     * @param mixed $metaValue
     */
    public function recordPostMetaUpdated(int $metaId, int $postId, string $metaKey, $metaValue = null): void
    {
        $this->recordPostMetaChange('activity.post_meta_updated', $postId, $metaKey);
    }

    /**
     * @param mixed $metaIds
     * @param mixed $metaValue
     */
    public function recordPostMetaDeleted($metaIds, int $postId, string $metaKey, $metaValue = null): void
    {
        $this->recordPostMetaChange('activity.post_meta_deleted', $postId, $metaKey, true);
    }

    private function recordPostMetaChange(string $event, int $postId, string $metaKey, bool $warning = false): void
    {
        if (!$this->shouldAuditPostMetaKey($metaKey)) {
            return;
        }

        $post = null;
        if (function_exists('get_post')) {
            $found = get_post($postId);
            $post = is_object($found) ? $found : null;
        }

        if (!$this->shouldAuditPostActivity($post)) {
            return;
        }

        $context = $this->postAuditContext($post);
        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Audit-log context key, not a database query argument.
        $context['meta_key'] = $this->auditText($metaKey, 120);
        $context['changed_fields'] = 'custom field: ' . $context['meta_key'];

        $warning ? $this->logger()->warning($event, $context) : $this->logger()->info($event, $context);
    }

    /**
     * @param mixed $terms
     * @param mixed $ttIds
     * @param mixed $append
     * @param mixed $oldTtIds
     */
    public function recordObjectTermsSet(int $objectId, $terms, $ttIds, string $taxonomy, $append = false, $oldTtIds = []): void
    {
        $post = null;
        if (function_exists('get_post')) {
            $found = get_post($objectId);
            $post = is_object($found) ? $found : null;
        }

        if (!$this->shouldAuditPostActivity($post) || !$this->shouldAuditTaxonomy($taxonomy)) {
            return;
        }

        $newIds = is_array($ttIds) ? array_map('strval', $ttIds) : [];
        $oldIds = is_array($oldTtIds) ? array_map('strval', $oldTtIds) : [];
        sort($newIds);
        sort($oldIds);

        if ($newIds === $oldIds) {
            return;
        }

        $context = $this->postAuditContext($post);
        $context['taxonomy'] = $this->auditText($taxonomy, 80);
        $context['term_count'] = count($newIds);
        $context['old_term_count'] = count($oldIds);
        $context['append'] = !empty($append) ? 'yes' : 'no';
        $context['changed_fields'] = 'terms: ' . $context['taxonomy'];

        $this->logger()->info('activity.post_terms_changed', $context);
    }

    public function recordTermCreated(int $termId, int $ttId, string $taxonomy): void
    {
        $this->recordTermChange('activity.term_created', $termId, $taxonomy);
    }

    public function recordTermUpdated(int $termId, int $ttId, string $taxonomy): void
    {
        $this->recordTermChange('activity.term_updated', $termId, $taxonomy);
    }

    /**
     * @param mixed $deletedTerm
     * @param mixed $objectIds
     */
    public function recordTermDeleted(int $termId, int $ttId, string $taxonomy, $deletedTerm = null, $objectIds = []): void
    {
        $this->recordTermChange('activity.term_deleted', $termId, $taxonomy, is_object($deletedTerm) ? $deletedTerm : null, true);
    }

    /**
     * @param object|null $term
     */
    private function recordTermChange(string $event, int $termId, string $taxonomy, $term = null, bool $warning = false): void
    {
        if (!$this->shouldAuditTaxonomy($taxonomy)) {
            return;
        }

        if (!is_object($term) && function_exists('get_term')) {
            $found = get_term($termId, $taxonomy);
            $term = is_object($found) ? $found : null;
        }

        $context = $this->activityBaseContext();
        $context['taxonomy'] = $this->auditText($taxonomy, 80);
        $context['term_id'] = $termId;
        $context['target'] = is_object($term) && isset($term->name)
            ? $this->auditText((string) $term->name, 140)
            : 'Term #' . (string) $termId;
        $context['summary'] = $this->formatAuditObjectAction($event, $context['taxonomy']);

        $warning ? $this->logger()->warning($event, $context) : $this->logger()->info($event, $context);
    }

    /**
     * @param mixed $menuData
     */
    public function recordMenuCreated(int $menuId, $menuData = []): void
    {
        $this->recordMenuChange('activity.menu_created', $menuId, $menuData);
    }

    /**
     * @param mixed $menuData
     */
    public function recordMenuUpdated(int $menuId, $menuData = []): void
    {
        $this->recordMenuChange('activity.menu_updated', $menuId, $menuData);
    }

    public function recordMenuDeleted(int $menuId): void
    {
        $this->recordMenuChange('activity.menu_deleted', $menuId, [], true);
    }

    /**
     * @param mixed $args
     */
    public function recordMenuItemUpdated(int $menuId, int $menuItemId, $args = []): void
    {
        $context = $this->activityBaseContext();
        $context['target'] = 'Menu item #' . (string) $menuItemId;
        $context['menu_id'] = $menuId;
        $context['menu_item_id'] = $menuItemId;
        $context['summary'] = 'Navigation menu item changed.';

        $this->logger()->info('activity.menu_item_updated', $context);
    }

    /**
     * @param mixed $menuData
     */
    private function recordMenuChange(string $event, int $menuId, $menuData = [], bool $warning = false): void
    {
        $context = $this->activityBaseContext();
        $context['menu_id'] = $menuId;
        $context['target'] = 'Menu #' . (string) $menuId;

        if (is_array($menuData) && isset($menuData['menu-name']) && is_scalar($menuData['menu-name'])) {
            $context['target'] = $this->auditText((string) $menuData['menu-name'], 140);
        } elseif (function_exists('wp_get_nav_menu_object')) {
            $menu = wp_get_nav_menu_object($menuId);
            if (is_object($menu) && isset($menu->name)) {
                $context['target'] = $this->auditText((string) $menu->name, 140);
            }
        }

        $context['summary'] = $this->formatAuditObjectAction($event, 'navigation menu');

        $warning ? $this->logger()->warning($event, $context) : $this->logger()->info($event, $context);
    }

    /**
     * @param mixed $value
     */
    public function recordWordPressSettingAdded(string $option, $value = null): void
    {
        $this->recordOptionChange('activity.setting_added', $option, null, $value);
    }

    /**
     * @param mixed $oldValue
     * @param mixed $value
     */
    public function recordWordPressSettingUpdated(string $option, $oldValue, $value): void
    {
        if ($option === AdminChangeShadowScanner::LAST_OPTION) {
            $this->recordShadowScanAudit($value);
            return;
        }

        $this->recordOptionChange('activity.setting_updated', $option, $oldValue, $value);
    }

    public function recordWordPressSettingDeleted(string $option): void
    {
        $this->recordOptionChange('activity.setting_deleted', $option, null, null, true);
    }

    /**
     * @param mixed $oldValue
     * @param mixed $value
     */
    private function recordOptionChange(string $event, string $option, $oldValue = null, $value = null, bool $warning = false): void
    {
        $definition = $this->auditOptionDefinition($option);
        if (empty($definition)) {
            return;
        }

        $context = $this->activityBaseContext();
        $context['option'] = $this->auditText($option, 140);
        $context['target'] = (string) ($definition['target'] ?? $option);
        $context['setting_area'] = (string) ($definition['area'] ?? 'WordPress settings');
        $context['summary'] = $this->formatAuditObjectAction($event, (string) ($definition['label'] ?? 'setting'));

        $oldLabel = $this->safeOptionValueLabel($option, $oldValue);
        $newLabel = $this->safeOptionValueLabel($option, $value);
        if ($oldLabel !== '' || $newLabel !== '') {
            $context['old_value'] = $oldLabel;
            $context['new_value'] = $newLabel;
        }

        $warning ? $this->logger()->warning($event, $context) : $this->logger()->info($event, $context);
    }

    /**
     * @param mixed $userdata
     */
    public function recordUserRegistered(int $userId, $userdata = []): void
    {
        $context = $this->targetUserContext($userId);
        $context['summary'] = 'User account created or registered.';

        $this->logger()->info('activity.user_created', $context);
    }

    /**
     * @param mixed $oldUserData
     * @param mixed $userdata
     */
    public function recordUserProfileUpdated(int $userId, $oldUserData = null, $userdata = []): void
    {
        $changed = [];
        $current = function_exists('get_userdata') ? get_userdata($userId) : null;

        foreach ([
            'user_email' => 'email',
            'display_name' => 'display name',
            'user_url' => 'URL',
        ] as $field => $label) {
            $old = is_object($oldUserData) && isset($oldUserData->{$field}) ? (string) $oldUserData->{$field} : '';
            $new = is_object($current) && isset($current->{$field}) ? (string) $current->{$field} : '';
            if ($old !== '' && $new !== '' && $old !== $new) {
                $changed[] = $label;
            }
        }

        $oldPass = is_object($oldUserData) && isset($oldUserData->user_pass) ? (string) $oldUserData->user_pass : '';
        $newPass = is_object($current) && isset($current->user_pass) ? (string) $current->user_pass : '';
        if ($oldPass !== '' && $newPass !== '' && $oldPass !== $newPass) {
            $changed[] = 'password';
        }

        if (empty($changed)) {
            return;
        }

        $context = $this->targetUserContext($userId);
        $context['changed_fields'] = implode(', ', array_unique($changed));
        $context['summary'] = 'User profile fields changed.';

        $this->logger()->warning('activity.user_profile_updated', $context);
    }

    /**
     * @param mixed $reassign
     * @param mixed $user
     */
    public function recordUserDeleted(int $userId, $reassign = null, $user = null): void
    {
        $context = $this->targetUserContext($userId, is_object($user) ? $user : null);
        $context['summary'] = 'User account deleted.';
        if (is_scalar($reassign) && (int) $reassign > 0) {
            $context['reassigned_to'] = (int) $reassign;
        }

        $this->logger()->warning('activity.user_deleted', $context);
    }

    /**
     * @param mixed $oldRoles
     */
    public function recordUserRoleSet(int $userId, string $role, $oldRoles = []): void
    {
        $oldRoleList = is_array($oldRoles) ? array_values(array_filter(array_map('strval', $oldRoles))) : [];
        $oldRoleText = implode(', ', $oldRoleList);
        if ($oldRoleText === $role) {
            return;
        }

        $context = $this->targetUserContext($userId);
        $context['old_role'] = $oldRoleText;
        $context['new_role'] = $role;
        $context['changed_fields'] = 'role';
        $context['summary'] = 'User role changed.';

        $this->logger()->warning('activity.user_role_changed', $context);
    }

    /**
     * @param mixed $user
     * @param mixed $newPass
     */
    public function recordPasswordReset($user, $newPass = null): void
    {
        $userId = is_object($user) && isset($user->ID) ? (int) $user->ID : 0;
        $context = $this->targetUserContext($userId, is_object($user) ? $user : null);
        $context['changed_fields'] = 'password';
        $context['summary'] = 'User password was reset.';

        $this->logger()->warning('activity.user_password_reset', $context);
    }

    /**
     * @param mixed $userId
     */
    public function recordOtherSessionsDestroyed($userId = 0): void
    {
        $context = $this->targetUserContext(is_scalar($userId) ? (int) $userId : 0);
        $context['summary'] = 'Other active sessions were terminated.';

        $this->logger()->warning('activity.user_sessions_destroyed', $context);
    }

    public function recordPluginActivated(string $plugin, bool $networkWide = false): void
    {
        $context = $this->pluginAuditContext($plugin);
        $context['network_wide'] = $networkWide ? 'yes' : 'no';
        $context['summary'] = 'Plugin activated.';
        $this->logger()->warning('activity.plugin_activated', $context);
        $this->recordDatabaseTableSnapshotChanges('plugin_activated');
    }

    public function recordPluginDeactivated(string $plugin, bool $networkWide = false): void
    {
        $context = $this->pluginAuditContext($plugin);
        $context['network_wide'] = $networkWide ? 'yes' : 'no';
        $context['summary'] = 'Plugin deactivated.';
        $this->logger()->warning('activity.plugin_deactivated', $context);
    }

    /**
     * @param mixed $deleted
     */
    public function recordPluginDeleted(string $plugin, $deleted = true): void
    {
        if (empty($deleted)) {
            return;
        }

        $context = $this->pluginAuditContext($plugin);
        $context['summary'] = 'Plugin deleted.';
        $this->logger()->warning('activity.plugin_deleted', $context);
        $this->recordDatabaseTableSnapshotChanges('plugin_deleted');
    }

    /**
     * @param mixed               $upgrader
     * @param array<string,mixed> $hookExtra
     */
    public function recordPluginUpgraderAction($upgrader, array $hookExtra = []): void
    {
        $type = strtolower((string) ($hookExtra['type'] ?? ''));
        $action = strtolower((string) ($hookExtra['action'] ?? ''));
        if ($type === '' || $action === '') {
            return;
        }

        $items = $this->upgraderItems($hookExtra);
        $target = $items ? implode(', ', array_slice($items, 0, 5)) : $type;
        $context = $this->activityBaseContext();
        $context['target'] = $this->auditText($target, 180);
        $context['upgrader_type'] = $type;
        $context['upgrader_action'] = $action;
        $context['item_count'] = count($items);
        $context['summary'] = ucfirst($action) . ' ' . $type . ' completed.';

        if ($type === 'plugin') {
            $this->logger()->warning($action === 'install' ? 'activity.plugin_installed' : 'activity.plugin_updated', $context);
            $this->recordDatabaseTableSnapshotChanges('plugin_' . $action);
            return;
        }

        if ($type === 'theme') {
            $this->logger()->warning($action === 'install' ? 'activity.theme_installed' : 'activity.theme_updated', $context);
            return;
        }

        if ($type === 'core') {
            $this->logger()->warning('activity.core_updated', $context);
        }
    }

    /**
     * @param mixed $newTheme
     * @param mixed $oldTheme
     */
    public function recordThemeSwitched(string $newName, $newTheme = null, $oldTheme = null): void
    {
        $context = $this->activityBaseContext();
        $context['target'] = $this->auditText($newName, 140);
        if (is_object($oldTheme) && method_exists($oldTheme, 'get')) {
            $context['old_theme'] = $this->auditText((string) $oldTheme->get('Name'), 140);
        }
        $context['summary'] = 'Active theme switched.';

        $this->logger()->warning('activity.theme_switched', $context);
    }

    public function recordThemeDeleted(string $stylesheet): void
    {
        $context = $this->activityBaseContext();
        $context['target'] = $this->auditText($stylesheet, 140);
        $context['theme'] = $context['target'];
        $context['summary'] = 'Theme deleted.';

        $this->logger()->warning('activity.theme_deleted', $context);
    }

    /**
     * @param mixed $site
     * @param mixed $args
     */
    public function recordNetworkSiteCreated($site, $args = []): void
    {
        $this->recordNetworkSiteChange('activity.network_site_created', $site, 'Network site created.');
    }

    /**
     * @param mixed $site
     */
    public function recordNetworkSiteDeleted($site): void
    {
        $this->recordNetworkSiteChange('activity.network_site_deleted', $site, 'Network site deleted.', true);
    }

    /**
     * @param mixed $blogId
     */
    public function recordNetworkSiteArchived($blogId): void
    {
        $this->recordNetworkSiteChange('activity.network_site_archived', $blogId, 'Network site archived.', true);
    }

    /**
     * @param mixed $blogId
     */
    public function recordNetworkSiteUnarchived($blogId): void
    {
        $this->recordNetworkSiteChange('activity.network_site_unarchived', $blogId, 'Network site unarchived.');
    }

    /**
     * @param mixed $blogId
     */
    public function recordNetworkSiteMarkedSpam($blogId): void
    {
        $this->recordNetworkSiteChange('activity.network_site_spam', $blogId, 'Network site marked as spam.', true);
    }

    /**
     * @param mixed $blogId
     */
    public function recordNetworkSiteMarkedHam($blogId): void
    {
        $this->recordNetworkSiteChange('activity.network_site_not_spam', $blogId, 'Network site restored from spam.');
    }

    /**
     * @param mixed $userId
     * @param mixed $role
     * @param mixed $blogId
     */
    public function recordUserAddedToSite($userId, $role = '', $blogId = 0): void
    {
        $context = $this->targetUserContext(is_scalar($userId) ? (int) $userId : 0);
        $context['site_id'] = is_scalar($blogId) ? (int) $blogId : 0;
        $context['new_role'] = is_scalar($role) ? (string) $role : '';
        $context['summary'] = 'User added to network site.';

        $this->logger()->warning('activity.network_user_added', $context);
    }

    /**
     * @param mixed $userId
     * @param mixed $blogId
     * @param mixed $reassign
     */
    public function recordUserRemovedFromSite($userId, $blogId = 0, $reassign = null): void
    {
        $context = $this->targetUserContext(is_scalar($userId) ? (int) $userId : 0);
        $context['site_id'] = is_scalar($blogId) ? (int) $blogId : 0;
        $context['summary'] = 'User removed from network site.';

        $this->logger()->warning('activity.network_user_removed', $context);
    }

    /**
     * @return array<string,mixed>
     */
    private function activityBaseContext(): array
    {
        $context = $this->userAuditContext();
        $context['ip'] = $this->auditClientIp();

        return $context;
    }

    /**
     * @param object|null $user
     * @return array<string,mixed>
     */
    private function targetUserContext(int $userId, $user = null): array
    {
        $context = $this->activityBaseContext();

        if (!is_object($user) && $userId > 0 && function_exists('get_userdata')) {
            $found = get_userdata($userId);
            $user = is_object($found) ? $found : null;
        }

        $targetLogin = is_object($user) && isset($user->user_login) ? (string) $user->user_login : '';
        $roles = $this->userRoleNames($user);
        $context['target_user_id'] = $userId;
        $context['target_user_login'] = $this->auditText($targetLogin, 80);
        $context['target'] = $targetLogin !== '' ? $context['target_user_login'] : 'User #' . (string) $userId;
        if (!empty($roles)) {
            $context['target_role'] = implode(', ', array_slice($roles, 0, 3));
        }

        return $context;
    }

    /**
     * @return array<string,string>
     */
    private function auditOptionDefinition(string $option): array
    {
        $option = trim($option);
        if ($option === '' || $option === self::DATABASE_TABLE_SNAPSHOT_OPTION) {
            return [];
        }

        if (strpos($option, 'shieldx_') === 0 || strpos($option, '_transient_') === 0 || strpos($option, '_site_transient_') === 0) {
            return [];
        }

        if (in_array($option, [
            'active_plugins',
            'auto_core_update_notified',
            'cron',
            'recently_activated',
            'rewrite_rules',
            'uninstall_plugins',
            'update_core',
            'update_plugins',
            'update_themes',
        ], true)) {
            return [];
        }

        if ($option === 'sidebars_widgets' || strpos($option, 'widget_') === 0) {
            return [
                'area' => 'Widgets',
                'label' => 'widget setting',
                'target' => $option === 'sidebars_widgets' ? 'Widget placement' : str_replace('_', ' ', $option),
            ];
        }

        $core = [
            'admin_email' => 'Administration email',
            'blog_public' => 'Search engine visibility',
            'blogdescription' => 'Site tagline',
            'blogname' => 'Site title',
            'category_base' => 'Category base',
            'comment_registration' => 'Comment registration',
            'date_format' => 'Date format',
            'default_comment_status' => 'Default comment status',
            'default_ping_status' => 'Default ping status',
            'default_role' => 'Default user role',
            'home' => 'Site URL',
            'large_size_h' => 'Large image height',
            'large_size_w' => 'Large image width',
            'medium_size_h' => 'Medium image height',
            'medium_size_w' => 'Medium image width',
            'page_for_posts' => 'Posts page',
            'page_on_front' => 'Homepage',
            'permalink_structure' => 'Permalink structure',
            'posts_per_page' => 'Posts per page',
            'show_on_front' => 'Homepage display',
            'siteurl' => 'WordPress URL',
            'start_of_week' => 'Week start day',
            'tag_base' => 'Tag base',
            'thumbnail_size_h' => 'Thumbnail height',
            'thumbnail_size_w' => 'Thumbnail width',
            'time_format' => 'Time format',
            'timezone_string' => 'Timezone',
            'upload_path' => 'Upload path',
            'upload_url_path' => 'Upload URL path',
            'users_can_register' => 'Membership registration',
        ];

        if (isset($core[$option])) {
            return [
                'area' => 'WordPress settings',
                'label' => 'WordPress setting',
                'target' => $core[$option],
            ];
        }

        foreach ($this->thirdPartyOptionPrefixes() as $prefix => $label) {
            if ($option === $prefix || strpos($option, $prefix . '_') === 0 || strpos($option, $prefix . '-') === 0) {
                return [
                    'area' => $label,
                    'label' => 'third-party plugin setting',
                    'target' => $label . ': ' . $option,
                ];
            }
        }

        return [];
    }

    /**
     * @return array<string,string>
     */
    private function thirdPartyOptionPrefixes(): array
    {
        return [
            'acf' => 'Advanced Custom Fields',
            'gravityforms' => 'Gravity Forms',
            'gform' => 'Gravity Forms',
            'mainwp' => 'MainWP',
            'managewp' => 'ManageWP',
            'rank_math' => 'RankMath',
            'termly' => 'Termly',
            'woocommerce' => 'WooCommerce',
            'wp_umbrella' => 'WP Umbrella',
            'wpforms' => 'WPForms',
            'wpseo' => 'Yoast SEO',
        ];
    }

    /**
     * @param mixed $value
     */
    private function safeOptionValueLabel(string $option, $value): string
    {
        if ($value === null || $this->looksSensitiveKey($option)) {
            return '';
        }

        if (is_bool($value)) {
            return $value ? 'enabled' : 'disabled';
        }

        if (is_int($value) || is_float($value)) {
            return (string) $value;
        }

        if (is_string($value)) {
            return $this->auditText($value, 120);
        }

        if (is_array($value)) {
            return 'array(' . count($value) . ')';
        }

        if (is_object($value)) {
            return 'object(' . get_class($value) . ')';
        }

        return '';
    }

    private function looksSensitiveKey(string $key): bool
    {
        return preg_match('/(?:password|pass|secret|token|key|salt|nonce|session|cookie|auth|email)/i', $key) === 1;
    }

    private function shouldAuditPostMetaKey(string $metaKey): bool
    {
        $metaKey = trim($metaKey);
        if ($metaKey === '' || $this->looksSensitiveKey($metaKey)) {
            return false;
        }

        return !in_array($metaKey, [
            '_edit_last',
            '_edit_lock',
            '_encloseme',
            '_pingme',
            '_wp_desired_post_slug',
            '_wp_old_date',
            '_wp_old_slug',
            '_wp_trash_meta_status',
            '_wp_trash_meta_time',
        ], true);
    }

    private function shouldAuditTaxonomy(string $taxonomy): bool
    {
        $taxonomy = trim($taxonomy);
        if ($taxonomy === '' || in_array($taxonomy, ['nav_menu', 'link_category', 'post_format'], true)) {
            return false;
        }

        if (function_exists('get_taxonomy')) {
            $object = get_taxonomy($taxonomy);
            if (is_object($object)) {
                $isPublic = isset($object->public) && (bool) $object->public;
                $hasAdminUi = isset($object->show_ui) && (bool) $object->show_ui;

                return $isPublic || $hasAdminUi;
            }
        }

        return true;
    }

    /**
     * @return array<string,mixed>
     */
    private function pluginAuditContext(string $plugin): array
    {
        $context = $this->activityBaseContext();
        $plugin = str_replace('\\', '/', $plugin);
        $context['plugin'] = $this->auditText($plugin, 180);
        $context['target'] = $context['plugin'];

        return $context;
    }

    /**
     * @param array<string,mixed> $hookExtra
     * @return array<int,string>
     */
    private function upgraderItems(array $hookExtra): array
    {
        $items = [];
        foreach (['plugin', 'plugins', 'theme', 'themes', 'translations'] as $key) {
            if (!isset($hookExtra[$key])) {
                continue;
            }

            $value = $hookExtra[$key];
            if (is_array($value)) {
                $items = array_merge($items, array_map('strval', $value));
            } elseif (is_scalar($value)) {
                $items[] = (string) $value;
            }
        }

        return array_values(array_unique(array_filter($items, static function (string $item): bool {
            return trim($item) !== '';
        })));
    }

    /**
     * @param mixed $site
     */
    private function recordNetworkSiteChange(string $event, $site, string $summary, bool $warning = false): void
    {
        $context = $this->activityBaseContext();
        $siteId = $this->siteIdFromValue($site);
        $context['site_id'] = $siteId;
        $context['target'] = $siteId > 0 ? 'Site #' . (string) $siteId : 'Network site';
        if (is_object($site) && isset($site->domain)) {
            $context['target'] = $this->auditText((string) $site->domain . (isset($site->path) ? (string) $site->path : ''), 180);
        }
        $context['summary'] = $summary;

        $warning ? $this->logger()->warning($event, $context) : $this->logger()->info($event, $context);
    }

    /**
     * @param mixed $value
     */
    private function siteIdFromValue($value): int
    {
        if (is_object($value) && isset($value->blog_id)) {
            return (int) $value->blog_id;
        }

        return is_scalar($value) ? (int) $value : 0;
    }

    /**
     * @param mixed $value
     */
    private function recordShadowScanAudit($value): void
    {
        if (!is_array($value)) {
            return;
        }

        $changes = (int) ($value['changes'] ?? 0);
        if ($changes < 1) {
            return;
        }

        $context = $this->activityBaseContext();
        $context['target'] = (string) ($value['scope_label'] ?? $value['label'] ?? 'Site files');
        $context['file_changes'] = $changes;
        $context['scan_id'] = (string) ($value['scan_id'] ?? '');
        $context['summary'] = sprintf('%d changed site file(s) detected after an admin action.', $changes);

        $this->logger()->warning('activity.site_files_changed', $context);
    }

    private function recordDatabaseTableSnapshotChanges(string $source): void
    {
        if (!function_exists('get_option') || !function_exists('update_option')) {
            return;
        }

        $tables = $this->currentDatabaseTables($source !== 'maintenance');
        if (empty($tables)) {
            return;
        }

        $known = get_option(self::DATABASE_TABLE_SNAPSHOT_OPTION, null);
        if (!is_array($known)) {
            update_option(self::DATABASE_TABLE_SNAPSHOT_OPTION, $tables, false);
            return;
        }

        $known = array_values(array_unique(array_map('strval', $known)));
        $added = array_values(array_diff($tables, $known));
        $removed = array_values(array_diff($known, $tables));
        if (empty($added) && empty($removed)) {
            return;
        }

        $context = $this->activityBaseContext();
        $context['target'] = 'WordPress database';
        $context['source'] = $source;
        $context['tables_added'] = implode(', ', array_slice($added, 0, 12));
        $context['tables_removed'] = implode(', ', array_slice($removed, 0, 12));
        $context['summary'] = sprintf('%d table(s) added, %d table(s) removed.', count($added), count($removed));

        $this->logger()->warning('activity.database_tables_changed', $context);
        update_option(self::DATABASE_TABLE_SNAPSHOT_OPTION, $tables, false);
    }

    /**
     * @return array<int,string>
     */
    private function currentDatabaseTables(bool $forceRefresh = false): array
    {
        global $wpdb;

        if (!is_object($wpdb) || !isset($wpdb->prefix) || !method_exists($wpdb, 'get_col') || !method_exists($wpdb, 'prepare')) {
            return [];
        }

        if (!$forceRefresh && is_array($this->databaseTablesCache)) {
            return $this->databaseTablesCache;
        }

        $cacheKey = self::databaseTablesCacheKey();
        if (!$forceRefresh && function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached)) {
                $tables = array_values(array_unique(array_filter(array_map('strval', $cached))));
                sort($tables);
                $this->databaseTablesCache = $tables;

                return $tables;
            }
        }

        $prefix = (string) $wpdb->prefix;
        $like = method_exists($wpdb, 'esc_like') ? $wpdb->esc_like($prefix) . '%' : str_replace(['%', '_'], ['\\%', '\\_'], $prefix) . '%';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Audits the local SHIELD-X table inventory after admin/runtime events; cached above for non-forced maintenance calls.
        $tables = $wpdb->get_col($wpdb->prepare('SHOW TABLES LIKE %s', $like));
        if (!is_array($tables)) {
            return [];
        }

        $tables = array_values(array_unique(array_filter(array_map('strval', $tables))));
        sort($tables);
        $this->databaseTablesCache = $tables;
        if (function_exists('set_transient')) {
            set_transient($cacheKey, $tables, self::DATABASE_TABLES_CACHE_TTL_SECONDS);
        }

        return $tables;
    }

    private static function databaseTablesCacheKey(): string
    {
        $version = function_exists('get_option')
            ? max(1, (int) get_option(self::DATABASE_TABLES_CACHE_VERSION_OPTION, 1))
            : 1;

        return self::DATABASE_TABLES_CACHE_TRANSIENT . '_v' . (string) $version;
    }

    private function formatAuditObjectAction(string $event, string $object): string
    {
        if (strpos($event, '_deleted') !== false) {
            return ucfirst($object) . ' deleted.';
        }

        if (strpos($event, '_created') !== false || strpos($event, '_added') !== false) {
            return ucfirst($object) . ' created.';
        }

        return ucfirst($object) . ' changed.';
    }

    private function auditClientIp(): string
    {
        return MinimalWaf::clientIpFromServer($_SERVER, $this->wafSettings());
    }

    /**
     * @param mixed  $user
     * @return array<string,mixed>
     */
    private function userAuditContext(int $userId = 0, $user = null, string $fallbackLogin = ''): array
    {
        if ($userId <= 0 && function_exists('get_current_user_id')) {
            $userId = (int) get_current_user_id();
        }

        if (!is_object($user) && $userId > 0 && function_exists('get_userdata')) {
            $found = get_userdata($userId);
            $user = is_object($found) ? $found : null;
        }

        if ($userId <= 0 && is_object($user) && isset($user->ID)) {
            $userId = (int) $user->ID;
        }

        $roles = $this->userRoleNames($user);
        $userLogin = $fallbackLogin;
        if ($userLogin === '' && is_object($user) && isset($user->user_login)) {
            $userLogin = (string) $user->user_login;
        }

        return [
            'user_id' => $userId,
            'user_login' => $this->auditText($userLogin, 80),
            'role' => implode(', ', array_slice($roles, 0, 3)),
        ];
    }

    /**
     * @param mixed $user
     * @return array<int,string>
     */
    private function userRoleNames($user): array
    {
        if (!is_object($user) || !isset($user->roles) || !is_array($user->roles)) {
            return [];
        }

        $roles = [];
        foreach ($user->roles as $role) {
            $role = trim((string) $role);
            if ($role !== '') {
                $roles[] = $role;
            }
        }

        return array_values(array_unique($roles));
    }

    /**
     * @param mixed $post
     */
    private function shouldAuditPostActivity($post): bool
    {
        if (!is_object($post) || !isset($post->ID)) {
            return false;
        }

        $postId = (int) $post->ID;
        if ($postId <= 0) {
            return false;
        }

        if (function_exists('wp_is_post_revision') && wp_is_post_revision($postId)) {
            return false;
        }

        if (function_exists('wp_is_post_autosave') && wp_is_post_autosave($postId)) {
            return false;
        }

        $postType = isset($post->post_type) ? (string) $post->post_type : '';
        if ($postType === '' || in_array($postType, [
            'attachment',
            'custom_css',
            'customize_changeset',
            'nav_menu_item',
            'oembed_cache',
            'revision',
            'user_request',
            'wp_block',
            'wp_global_styles',
            'wp_navigation',
            'wp_template',
            'wp_template_part',
        ], true)) {
            return false;
        }

        $status = isset($post->post_status) ? (string) $post->post_status : '';
        if (in_array($status, ['auto-draft', 'inherit'], true)) {
            return false;
        }

        if (function_exists('get_post_type_object')) {
            $typeObject = get_post_type_object($postType);
            if (is_object($typeObject)) {
                $isPublic = isset($typeObject->public) && (bool) $typeObject->public;
                $hasAdminUi = isset($typeObject->show_ui) && (bool) $typeObject->show_ui;
                if (!$isPublic && !$hasAdminUi) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * @param mixed $post
     * @return array<string,mixed>
     */
    private function postAuditContext($post): array
    {
        $context = $this->userAuditContext();
        $context['ip'] = $this->auditClientIp();

        if (!is_object($post)) {
            return $context;
        }

        $context['post_id'] = isset($post->ID) ? (int) $post->ID : 0;
        $context['post_type'] = isset($post->post_type) ? $this->auditText((string) $post->post_type, 60) : '';
        $context['post_status'] = isset($post->post_status) ? $this->auditText((string) $post->post_status, 60) : '';
        $title = isset($post->post_title) ? $this->auditText((string) $post->post_title, 140) : '';
        if ($title !== '') {
            $context['title'] = $title;
        }

        return $context;
    }

    /**
     * @param mixed $postAfter
     * @param mixed $postBefore
     * @return array<int,string>
     */
    private function changedPostFields($postAfter, $postBefore): array
    {
        if (!is_object($postAfter) || !is_object($postBefore)) {
            return [];
        }

        $fields = [
            'post_title' => 'title',
            'post_name' => 'slug',
            'post_content' => 'content',
            'post_excerpt' => 'excerpt',
            'post_parent' => 'parent',
            'menu_order' => 'menu_order',
            'comment_status' => 'comment_status',
            'ping_status' => 'ping_status',
        ];
        $changed = [];

        foreach ($fields as $field => $label) {
            $after = isset($postAfter->{$field}) ? (string) $postAfter->{$field} : '';
            $before = isset($postBefore->{$field}) ? (string) $postBefore->{$field} : '';
            if ($after !== $before) {
                $changed[] = $label;
            }
        }

        return $changed;
    }

    private function auditText(string $text, int $limit): string
    {
        $text = wp_strip_all_tags($text);
        $text = preg_replace('/\s+/', ' ', $text) ?: '';
        $text = trim($text);

        return strlen($text) > $limit ? substr($text, 0, max(0, $limit - 3)) . '...' : $text;
    }

    /**
     * @return array<string,mixed>
     */
    private function scheduledScanSummary(bool $includeExternalToken = true): array
    {
        $settings = $this->scanSettings()->all();
        $state = ScheduledScanRunner::scheduleState();
        $sections = isset($settings['sections']) && is_array($settings['sections']) ? $settings['sections'] : [];
        $sectionState = isset($state['sections']) && is_array($state['sections']) ? $state['sections'] : [];

        foreach (ScheduledScanSettings::types() as $type) {
            $section = isset($sections[$type]) && is_array($sections[$type]) ? $sections[$type] : $this->scanSettings()->section($type);
            $runtime = isset($sectionState[$type]) && is_array($sectionState[$type]) ? $sectionState[$type] : [];
            $sections[$type] = array_merge($section, $runtime);
        }

        $summary = array_merge($settings, $state);
        $summary['sections'] = $sections;

        return $includeExternalToken ? $summary : $this->redactExternalRunnerTokens($summary);
    }

    /**
     * @return array<string,mixed>
     */
    private function scheduledBackupSummary(bool $includeExternalToken = true): array
    {
        $summary = array_merge($this->backupSettings()->all(), ScheduledBackupRunner::scheduleState());

        return $includeExternalToken ? $summary : $this->redactExternalRunnerTokens($summary);
    }

    /**
     * @return array<string,mixed>
     */
    private function scheduledRestorePointSummary(bool $includeExternalToken = true): array
    {
        $summary = array_merge($this->restorePointScheduleSettings()->all(), ScheduledRestorePointRunner::scheduleState());

        return $includeExternalToken ? $summary : $this->redactExternalRunnerTokens($summary);
    }

    /**
     * @param array<string,mixed> $summary
     * @return array<string,mixed>
     */
    private function redactExternalRunnerTokens(array $summary): array
    {
        if (array_key_exists('external_token', $summary)) {
            $summary['external_token_configured'] = trim((string) $summary['external_token']) !== '';
            unset($summary['external_token']);
        }

        unset($summary['external_token_hash']);

        if (isset($summary['sections']) && is_array($summary['sections'])) {
            foreach ($summary['sections'] as $type => $section) {
                if (is_array($section)) {
                    $summary['sections'][$type] = $this->redactExternalRunnerTokens($section);
                }
            }
        }

        return $summary;
    }

    /**
     * Builds only the status sections rendered by a SHIELD-X admin route.
     *
     * The complete status document remains available through status() for REST
     * and CLI consumers. Admin pages are loaded through a separate AJAX request,
     * so gathering every backup, log, profiler, and system detail here made each
     * route pay for unrelated work.
     *
     * @return array<string,mixed>
     */
    public function adminStatus(string $page): array
    {
        $page = strtolower(trim($page));
        $page = preg_replace('/[^a-z0-9_-]/', '', $page) ?: 'shield-x';

        if (isset($this->adminStatusCache[$page])) {
            return $this->adminStatusCache[$page];
        }

        $sections = self::adminStatusSections($page);
        $environment = [];
        $compatibilitySummary = [];
        $schema = [];
        $waf = [];
        $rateLimit = [];
        $scanner = [];
        $addons = [];
        $loginSecurity = [];
        $lockdown = [];
        $hardening = [];
        $maintenance = [];

        if (!empty($sections['environment'])) {
            $environment = $this->environment()->detect();
            $compatibility = isset($environment['compatibility']) && is_array($environment['compatibility'])
                ? $environment['compatibility']
                : [];
            $compatibilitySummary = isset($compatibility['summary']) && is_array($compatibility['summary'])
                ? $compatibility['summary']
                : [];
        }

        if (!empty($sections['schema'])) {
            $installer = SchemaInstaller::fromWordPress();
            $schema = [
                'version' => $installer->installedVersion(),
                'expected_version' => SchemaInstaller::SCHEMA_VERSION,
                'tables' => $installer->tableNames(),
            ];
        }

        if (!empty($sections['waf'])) {
            $rules = $this->waf()->rules();
            $ruleSummary = $this->waf()->ruleSummary();
            $waf = [
                'mode' => $this->waf()->mode(),
                'engine_version' => $this->waf()->engineVersion(),
                'rules_loaded' => count($rules),
                'rules_enabled' => (int) ($ruleSummary['enabled'] ?? 0),
                'rule_summary' => $ruleSummary,
                'category_counts' => $ruleSummary['categories'] ?? [],
                'severity_counts' => $ruleSummary['severities'] ?? [],
                'rules' => $rules,
                'stats' => $this->wafStats()->summary(86400, 30),
                'settings' => $this->wafSettings()->all(),
                'available_plugins' => $this->wafSettings()->availablePlugins(),
                'rule_feed' => [
                    'name' => 'SHIELD-X Managed PHP Regex WAF Rules',
                    'strategy' => 'SHIELD-X bundled regex catalog tuned for WordPress shared hosting',
                    'update_channel' => 'bundled_mvp',
                    'rule_family_reference' => 'WordPress request hardening and application security pattern families',
                    'license_reference' => 'SHIELD-X-authored bundled rule catalog',
                ],
            ];
        }

        if (!empty($sections['rate_limit'])) {
            $rateLimit = $this->rateLimiter()->summary();
        }

        if (!empty($sections['scanner'])) {
            // Scope-card counts are only a presentation hint. A cold count can
            // recursively walk large directories without affecting scan coverage,
            // so never make an admin page wait for it.
            $scanner = $this->scanner()->summary(false);
            $scanner['schedule'] = $this->scheduledScanSummary();
            $scanner['vulnerability'] = VulnerabilityScanner::fromWordPress()->status();
        }

        if (!empty($sections['addons'])) {
            $addons = AddonRegistry::fromWordPress()->summary();
        }

        if (!empty($sections['login_security'])) {
            $loginSecurity = $this->loginSecurity()->summary();
        }

        if (!empty($sections['lockdown'])) {
            $lockdown = $this->lockdown()->status();
        }

        if (!empty($sections['hardening'])) {
            $hardening = $this->hardeningSettings()->all();
            $hardening['wp_config_file_edit'] = Hardening::wpConfigFileEditStatus();
            $hardening['php_disabled_functions'] = Hardening::phpDisabledFunctionsStatus();
            $hardening['web_server_rules'] = Hardening::webServerRuleStatus($this->hardeningSettings());
            $hardening['security_measures'] = Hardening::securityMeasures($this->hardeningSettings());
        }

        if (!empty($sections['database_tables'])) {
            $maintenance['database_tables'] = DatabaseTableReporter::fromWordPress()->report();
        }

        if (!empty($sections['database_backups'])) {
            $backupManager = DatabaseBackupManager::fromWordPress();
            $maintenance['database_backups'] = [
                'directory' => $backupManager->backupDirectory(),
                'recent' => $backupManager->recent(8),
                'schedule' => $this->scheduledBackupSummary(),
            ];
        }

        if (!empty($sections['restore_points'])) {
            $restorePoints = RestorePointManager::fromWordPress();
            $maintenance['restore_points'] = [
                'directory' => $restorePoints->restoreDirectory(),
                'recent' => $restorePoints->recent(8),
                'schedule' => $this->scheduledRestorePointSummary(),
            ];
        }

        if (!empty($sections['file_shadow_copies'])) {
            $maintenance['file_shadow_copies'] = FileShadowCopyManager::fromWordPress()->summary(12);
        }

        if (!empty($sections['backup_storage'])) {
            $maintenance['backup_storage'] = BackupStorageManager::fromWordPress()->summary();
        }

        if (!empty($sections['log_rotation'])) {
            $maintenance['log_rotation'] = LogRotator::fromWordPress()->status();
        }

        if (!empty($sections['update_coordination'])) {
            $maintenance['update_coordination'] = UpdateCoordinator::status();
        }

        $status = [
            'plugin' => [
                'name' => 'SHIELD-X',
                'version' => SHIELDX_VERSION,
                'text_domain' => SHIELDX_TEXT_DOMAIN,
            ],
            'wordpress' => [
                'version' => function_exists('get_bloginfo') ? get_bloginfo('version') : null,
                'site_url' => function_exists('site_url') ? site_url() : null,
            ],
            'environment' => $environment,
            'storage' => $this->storageStatus(),
            'waf' => $waf,
            'hardening' => $hardening,
            'lockdown' => $lockdown,
            'login_security' => $loginSecurity,
            'posture' => SecurityPosture::fromWordPress()->summary(),
            'licensing' => $this->licenseManager()->summaryForCurrentSession(),
            'human_challenge' => !empty($sections['human_challenge']) ? HumanChallengeSettings::fromWordPress()->all() : [],
            'addons' => $addons,
            'rate_limit' => $rateLimit,
            'scanner' => $scanner,
            'user_profiler' => !empty($sections['user_profiler']) ? $this->userProfiler()->summary() : [],
            'maintenance' => $maintenance,
            'schema' => $schema,
            'health' => [],
        ];

        if (!empty($sections['health'])) {
            $status['health'] = [
                'storage_ready' => $this->storage()->isWritable(),
                'schema_ready' => !empty($schema) && (string) ($schema['version'] ?? '') === SchemaInstaller::SCHEMA_VERSION,
                'lockdown_killswitch_path' => (string) ($lockdown['killswitch_path'] ?? ($this->storage()->baseDir() . '/lockdown/disable.json')),
                'rate_limit_storage_ready' => $rateLimit['storage_ready'] ?? false,
                'scanner_ready' => !empty($scanner['available']),
                'scheduled_scan_ready' => empty($scanner['schedule']['enabled']) || !empty($scanner['schedule']['next_run']) || !empty($scanner['schedule']['external_runner_enabled']),
                'compatibility_ready' => (($compatibilitySummary['missing'] ?? 0) === 0 && ($compatibilitySummary['module_missing'] ?? 0) === 0),
                'login_secret_storage_ready' => !empty($loginSecurity['secret_storage']['ready']),
                'login_security_ready' => empty($loginSecurity['enabled']) || empty($loginSecurity['two_factor_required_roles']) || ((int) ($loginSecurity['required_users_pending'] ?? 0) === 0),
                'lockdown_ready' => empty($lockdown['enabled']) || empty($lockdown['killswitch_active']),
            ];
        }

        $this->adminStatusCache[$page] = $status;

        return $status;
    }

    /**
     * @return array<string,mixed>
     */
    private function storageStatus(): array
    {
        $server = strtolower($this->serverText('SERVER_SOFTWARE', ''));
        $sapi = strtolower(PHP_SAPI);
        $insideWebRoot = $this->storage()->isInsideWebRoot();
        $webDenyRulesSupported = self::storageWebDenyRulesHonored($server, $sapi);
        $webDenyRulesHonored = !$insideWebRoot;
        $warning = '';

        if ($insideWebRoot && !$webDenyRulesHonored) {
            $warning = __('Private storage is inside the WordPress web root and this server may not honor SHIELD-X deny files. Move it outside the web root with SHIELDX_STORAGE_DIR to protect backups, restore points, quarantine samples, and logs.', 'xerotact-shield-x-security');
        }

        return [
            'base_dir' => $this->storage()->baseDir(),
            'writable' => $this->storage()->isWritable(),
            'directories' => $this->storage()->expectedDirectories(),
            'inside_web_root' => $insideWebRoot,
            'web_server' => $server !== '' ? $server : 'unknown',
            'php_sapi' => $sapi,
            'web_deny_rules_honored' => $webDenyRulesHonored,
            'web_deny_rules_supported' => $webDenyRulesSupported,
            'warning' => $warning,
        ];
    }

    private static function storageWebDenyRulesHonored(string $server, string $sapi): bool
    {
        return strpos($server, 'apache') !== false
            || strpos($sapi, 'apache') !== false
            || strpos($server, 'microsoft-iis') !== false
            || strpos($server, 'iis') !== false;
    }

    /**
     * @return array<string,bool>
     */
    private static function adminStatusSections(string $page): array
    {
        $overview = [
            'environment' => true,
            'schema' => true,
            'waf' => true,
            'rate_limit' => true,
            'scanner' => true,
            'addons' => true,
            'login_security' => true,
            'lockdown' => true,
            'hardening' => true,
            'human_challenge' => true,
            'database_backups' => true,
            'restore_points' => true,
            'log_rotation' => true,
            'health' => true,
        ];

        switch ($page) {
            case 'shield-x':
            case 'shield-x-setup':
                return $overview;
            case 'shield-x-addons':
                return ['addons' => true];
            case 'shield-x-waf':
            case 'shield-x-waf-access':
                return ['waf' => true];
            case 'shield-x-login-security':
                return [
                    'login_security' => true,
                    'human_challenge' => true,
                    'hardening' => true,
                    'rate_limit' => true,
                ];
            case 'shield-x-scan':
                return ['scanner' => true];
            case 'shield-x-backup':
                return [
                    'scanner' => true,
                    'database_backups' => true,
                    'restore_points' => true,
                    'file_shadow_copies' => true,
                    'backup_storage' => true,
                ];
            case 'shield-x-backup-restore':
                return ['restore_points' => true];
            case 'shield-x-hardening':
                return ['hardening' => true, 'rate_limit' => true];
            case 'shield-x-lockdown':
                return ['lockdown' => true];
            case 'shield-x-activity':
                return ['waf' => true, 'rate_limit' => true, 'user_profiler' => true];
            case 'shield-x-system':
                return array_merge($overview, [
                    'database_tables' => true,
                    'update_coordination' => true,
                ]);
            case 'shield-x-log-rotation':
                return ['log_rotation' => true];
            default:
                return $overview;
        }
    }

    /**
     * @return array<string,mixed>
     */
    public function status(): array
    {
        if (is_array($this->statusCache) && (microtime(true) - $this->statusCacheCreatedAt) <= self::STATUS_CACHE_TTL_SECONDS) {
            return $this->statusCache;
        }

        $installer = SchemaInstaller::fromWordPress();
        $environment = $this->environment()->detect();
        $rules = $this->waf()->rules();
        $ruleSummary = $this->waf()->ruleSummary();
        $rateLimitSummary = $this->rateLimiter()->summary();
        $scannerSummary = $this->scanner()->summary();
        $scannerSummary['schedule'] = $this->scheduledScanSummary(false);
        $scannerSummary['vulnerability'] = VulnerabilityScanner::fromWordPress()->status();
        $addonSummary = AddonRegistry::fromWordPress()->summary();
        $loginSecuritySummary = $this->loginSecurity()->summary();
        $lockdownSummary = $this->lockdown()->status();
        $compatibilitySummary = isset($environment['compatibility']['summary']) && is_array($environment['compatibility']['summary'])
            ? $environment['compatibility']['summary']
            : [];

        $hardeningSummary = $this->hardeningSettings()->all();
        $hardeningSummary['wp_config_file_edit'] = Hardening::wpConfigFileEditStatus();
        $hardeningSummary['php_disabled_functions'] = Hardening::phpDisabledFunctionsStatus();
        $hardeningSummary['web_server_rules'] = Hardening::webServerRuleStatus($this->hardeningSettings());
        $hardeningSummary['security_measures'] = Hardening::securityMeasures($this->hardeningSettings());
        $maintenanceSummary = [
            'database_tables' => DatabaseTableReporter::fromWordPress()->report(),
            'database_backups' => [
                'directory' => DatabaseBackupManager::fromWordPress()->backupDirectory(),
                'recent' => DatabaseBackupManager::fromWordPress()->recent(8),
                'schedule' => $this->scheduledBackupSummary(false),
            ],
            'restore_points' => [
                'directory' => RestorePointManager::fromWordPress()->restoreDirectory(),
                'recent' => RestorePointManager::fromWordPress()->recent(8),
                'schedule' => $this->scheduledRestorePointSummary(false),
            ],
            'file_shadow_copies' => FileShadowCopyManager::fromWordPress()->summary(12),
            'backup_storage' => BackupStorageManager::fromWordPress()->summary(),
            'log_rotation' => LogRotator::fromWordPress()->status(),
            'update_coordination' => UpdateCoordinator::status(),
        ];

        $status = [
            'plugin' => [
                'name' => 'SHIELD-X',
                'version' => SHIELDX_VERSION,
                'text_domain' => SHIELDX_TEXT_DOMAIN,
            ],
            'wordpress' => [
                'version' => function_exists('get_bloginfo') ? get_bloginfo('version') : null,
                'site_url' => function_exists('site_url') ? site_url() : null,
            ],
            'environment' => $environment,
            'storage' => $this->storageStatus(),
            'waf' => [
                'mode' => $this->waf()->mode(),
                'engine_version' => $this->waf()->engineVersion(),
                'rules_loaded' => count($rules),
                'rules_enabled' => (int) ($ruleSummary['enabled'] ?? 0),
                'rule_summary' => $ruleSummary,
                'category_counts' => $ruleSummary['categories'] ?? [],
                'severity_counts' => $ruleSummary['severities'] ?? [],
                'rules' => $rules,
                'stats' => $this->wafStats()->summary(86400, 30),
                'settings' => $this->wafSettings()->all(),
                'available_plugins' => $this->wafSettings()->availablePlugins(),
                'rule_feed' => [
                    'name' => 'SHIELD-X Managed PHP Regex WAF Rules',
                    'strategy' => 'SHIELD-X bundled regex catalog tuned for WordPress shared hosting',
                    'update_channel' => 'bundled_mvp',
                    'rule_family_reference' => 'WordPress request hardening and application security pattern families',
                    'license_reference' => 'SHIELD-X-authored bundled rule catalog',
                ],
            ],
            'hardening' => $hardeningSummary,
            'lockdown' => $lockdownSummary,
            'login_security' => $loginSecuritySummary,
            'posture' => SecurityPosture::fromWordPress()->summary(),
            'licensing' => $this->licenseManager()->summaryForCurrentSession(),
            'human_challenge' => HumanChallengeSettings::fromWordPress()->all(),
            'notifications' => NotificationSettings::fromWordPress()->summary(),
            'addons' => $addonSummary,
            'rate_limit' => $rateLimitSummary,
            'scanner' => $scannerSummary,
            'user_profiler' => $this->userProfiler()->summary(),
            'maintenance' => $maintenanceSummary,
            'schema' => [
                'version' => $installer->installedVersion(),
                'expected_version' => SchemaInstaller::SCHEMA_VERSION,
                'tables' => $installer->tableNames(),
            ],
            'health' => [
                'storage_ready' => $this->storage()->isWritable(),
                'schema_ready' => $installer->installedVersion() === SchemaInstaller::SCHEMA_VERSION,
                'lockdown_killswitch_path' => (string) ($lockdownSummary['killswitch_path'] ?? ($this->storage()->baseDir() . '/lockdown/disable.json')),
                'rate_limit_storage_ready' => $rateLimitSummary['storage_ready'] ?? false,
                'scanner_ready' => !empty($scannerSummary['available']),
                'scheduled_scan_ready' => empty($scannerSummary['schedule']['enabled']) || !empty($scannerSummary['schedule']['next_run']) || !empty($scannerSummary['schedule']['external_runner_enabled']),
                'compatibility_ready' => (($compatibilitySummary['missing'] ?? 0) === 0 && ($compatibilitySummary['module_missing'] ?? 0) === 0),
                'login_secret_storage_ready' => !empty($loginSecuritySummary['secret_storage']['ready']),
                'login_security_ready' => empty($loginSecuritySummary['enabled']) || empty($loginSecuritySummary['two_factor_required_roles']) || ((int) ($loginSecuritySummary['required_users_pending'] ?? 0) === 0),
                'lockdown_ready' => empty($lockdownSummary['enabled']) || empty($lockdownSummary['killswitch_active']),
            ],
        ];

        $this->statusCache = $status;
        $this->statusCacheCreatedAt = microtime(true);

        return $status;
    }

    public function clearStatusCache(): void
    {
        $this->statusCache = null;
        $this->statusCacheCreatedAt = 0.0;
        $this->adminStatusCache = [];
    }

    public function lockdown(): Lockdown
    {
        if (!$this->lockdown instanceof Lockdown) {
            $this->lockdown = Lockdown::fromWordPress($this->lockdownSettings());
        }

        return $this->lockdown;
    }

    /**
     * @return array<string,mixed>
     */
    public function lockdownStatus(): array
    {
        return $this->lockdown()->status();
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<string,mixed>
     */
    public function previewLockdown(array $policy = []): array
    {
        return $this->lockdown()->preview($policy ?: null);
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<string,mixed>
     */
    public function saveLockdownPolicy(array $policy): array
    {
        $this->lockdownSettings()->savePolicy($policy);
        $this->logger()->info('lockdown.policy_saved', [
            'read_only_enabled' => !empty($policy['read_only_enabled']),
            'no_exec_enabled' => !empty($policy['no_exec_enabled']),
            'disable_file_mods' => !empty($policy['disable_file_mods']),
            'directory_index_off' => !empty($policy['directory_index_off']),
        ]);

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

    /**
     * @param array<string,mixed> $policy
     * @return array<string,mixed>
     */
    public function applyLockdown(array $policy, bool $confirmed, bool $createRestorePoint = false, string $nonceAction = '', string $nonce = ''): array
    {
        $guardError = $this->lockdown()->adminWriteAuthorizationError($nonceAction, $nonce);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
            ];
        }

        if ($createRestorePoint && $confirmed && !$this->createRuntimeRestorePoint('pre_lockdown_enable')) {
            $result = $this->lockdownRestorePointFailure('enable');
            $this->logger()->warning('lockdown.applied', $result);

            return $result;
        }

        $result = $this->lockdown()->apply($policy, $confirmed, $nonceAction, $nonce);
        $context = [
            'ok' => !empty($result['ok']),
            'status' => (string) ($result['status'] ?? ''),
            'message' => (string) ($result['message'] ?? ''),
        ];
        if (empty($result['ok'])) {
            $this->logger()->warning('lockdown.applied', $context);
        } else {
            $this->logger()->info('lockdown.applied', $context);
        }

        return $result;
    }

    /**
     * @return array<string,mixed>
     */
    public function revertLockdown(bool $createRestorePoint = false): array
    {
        $restorePointWarning = '';
        if ($createRestorePoint && !$this->createRuntimeRestorePoint('pre_lockdown_disable')) {
            $restorePointWarning = __('SHIELD-X could not create a pre-change restore point. Lockdown revert proceeded without it.', 'xerotact-shield-x-security');
        }

        $result = $this->lockdown()->revert(true);
        if ($restorePointWarning !== '') {
            $result = $this->withLockdownRestorePointWarning($result, $restorePointWarning);
        }
        $context = [
            'ok' => !empty($result['ok']),
            'status' => (string) ($result['status'] ?? ''),
            'message' => (string) ($result['message'] ?? ''),
        ];
        if (empty($result['ok'])) {
            $this->logger()->warning('lockdown.reverted', $context);
        } else {
            $this->logger()->info('lockdown.reverted', $context);
        }

        return $result;
    }

    /**
     * Force-revert only the known SHIELD-X Lockdown markers and orphaned
     * permissions when the normal rollback manifest cannot be trusted.
     *
     * @return array<string,mixed>
     */
    public function forceRevertLockdownWithoutManifest(bool $confirmed, string $nonceAction = '', string $nonce = ''): array
    {
        $guardError = $this->lockdown()->adminWriteAuthorizationError($nonceAction, $nonce);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
            ];
        }

        $restorePointWarning = '';
        if ($confirmed && !$this->createRuntimeRestorePoint('pre_lockdown_force_revert')) {
            $restorePointWarning = __('SHIELD-X could not create a pre-change restore point. Force revert proceeded without it.', 'xerotact-shield-x-security');
        }

        $result = $this->lockdown()->forceRevertWithoutManifest($confirmed, $nonceAction, $nonce);
        if ($restorePointWarning !== '') {
            $result = $this->withLockdownRestorePointWarning($result, $restorePointWarning);
        }

        $context = [
            'ok' => !empty($result['ok']),
            'status' => (string) ($result['status'] ?? ''),
            'message' => (string) ($result['message'] ?? ''),
            'restored_permissions' => (int) ($result['restored_permissions'] ?? 0),
            'removed_blocks' => (int) ($result['removed_blocks'] ?? 0),
        ];
        $this->logger()->warning('lockdown.force_reverted', $context);

        return $result;
    }

    /**
     * @return array<string,mixed>
     */
    public function reapplyLockdown(bool $createRestorePoint = false): array
    {
        if ($createRestorePoint && !$this->createRuntimeRestorePoint('pre_lockdown_reapply')) {
            $result = $this->lockdownRestorePointFailure('reapply');
            $this->logger()->warning('lockdown.reapplied', $result);

            return $result;
        }

        $result = $this->lockdown()->reapply();
        $context = [
            'ok' => !empty($result['ok']),
            'status' => (string) ($result['status'] ?? ''),
            'message' => (string) ($result['message'] ?? ''),
        ];
        if (empty($result['ok'])) {
            $this->logger()->warning('lockdown.reapplied', $context);
        } else {
            $this->logger()->info('lockdown.reapplied', $context);
        }

        return $result;
    }

    /**
     * Reapply lockdown through the core administrator guard. This exists for
     * external write entry points such as WP-CLI; update/recovery lifecycle
     * calls continue to use reapplyLockdown().
     *
     * @return array<string,mixed>
     */
    public function reapplyLockdownForAuthorizedRequest(bool $createRestorePoint = false, string $nonceAction = '', string $nonce = ''): array
    {
        $guardError = $this->lockdown()->adminWriteAuthorizationError($nonceAction, $nonce);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
            ];
        }

        if ($createRestorePoint && !$this->createRuntimeRestorePoint('pre_lockdown_reapply')) {
            $result = $this->lockdownRestorePointFailure('reapply');
            $this->logger()->warning('lockdown.reapplied', $result);

            return $result;
        }

        $result = $this->lockdown()->reapplyForAuthorizedRequest($nonceAction, $nonce);
        $context = [
            'ok' => !empty($result['ok']),
            'status' => (string) ($result['status'] ?? ''),
            'message' => (string) ($result['message'] ?? ''),
        ];
        if (empty($result['ok'])) {
            $this->logger()->warning('lockdown.reapplied', $context);
        } else {
            $this->logger()->info('lockdown.reapplied', $context);
        }

        return $result;
    }

    /**
     * @param array<int,string> $scopes
     * @return array<string,mixed>
     */
    public function fixPermissions(array $scopes): array
    {
        $result = $this->lockdown()->fixPermissions($scopes);
        $failed = isset($result['failed_total'])
            ? (int) $result['failed_total']
            : (isset($result['failed']) && is_array($result['failed']) ? count($result['failed']) : 0);
        $context = [
            'ok' => !empty($result['ok']),
            'files' => (int) ($result['files'] ?? 0),
            'directories' => (int) ($result['directories'] ?? 0),
            'unchanged' => (int) ($result['unchanged'] ?? 0),
            'skipped' => (int) ($result['skipped'] ?? 0),
            'failed' => $failed,
            'scopes' => isset($result['scopes']) && is_array($result['scopes']) ? implode(',', array_map('strval', $result['scopes'])) : '',
        ];

        if (empty($result['ok'])) {
            $this->logger()->warning('lockdown.permission_fix', $context);
        } else {
            $this->logger()->info('lockdown.permission_fix', $context);
        }

        return $result;
    }

    /**
     * @param array<int,string> $scopes
     * @return array<string,mixed>
     */
    public function startProgressivePermissionFix(array $scopes): array
    {
        return $this->lockdown()->startProgressivePermissionFix($scopes);
    }

    /**
     * @return array<string,mixed>
     */
    public function runProgressivePermissionFixBatch(string $jobId): array
    {
        $result = $this->lockdown()->runProgressivePermissionFixBatch($jobId);
        $status = (string) ($result['status'] ?? '');
        if ($status !== 'completed' && $status !== 'failed') {
            return $result;
        }

        $failed = isset($result['failed_total'])
            ? (int) $result['failed_total']
            : (isset($result['failed']) && is_array($result['failed']) ? count($result['failed']) : 0);
        $context = [
            'ok' => !empty($result['ok']),
            'status' => $status,
            'files' => (int) ($result['files'] ?? 0),
            'directories' => (int) ($result['directories'] ?? 0),
            'unchanged' => (int) ($result['unchanged'] ?? 0),
            'skipped' => (int) ($result['skipped'] ?? 0),
            'failed' => $failed,
            'scopes' => isset($result['scopes']) && is_array($result['scopes']) ? implode(',', array_map('strval', $result['scopes'])) : '',
        ];

        if (empty($result['ok'])) {
            $this->logger()->warning('lockdown.permission_fix', $context);
        } else {
            $this->logger()->info('lockdown.permission_fix', $context);
        }

        return $result;
    }

    /**
     * @param array<int,string> $selection
     * @return array<string,mixed>
     */
    private function performEmergencyRecovery(int $userId, string $ip, string $method, array $selection): array
    {
        $actions = [];
        $selected = array_fill_keys($selection, true);
        $wafRecovery = null;
        $rateRecovery = null;

        if (!empty($selected['revert_lockdown'])) {
            $lockdownResult = $this->lockdown()->revert(true, 'emergency_recovery');
            $actions[] = [
                'label' => __('Lockdown rollback', 'xerotact-shield-x-security'),
                'status' => empty($lockdownResult['ok']) ? __('No manifest or failed', 'xerotact-shield-x-security') : __('Completed', 'xerotact-shield-x-security'),
                'detail' => (string) ($lockdownResult['message'] ?? ''),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Lockdown rollback', 'xerotact-shield-x-security'));
        }

        $hardening = HardeningSettings::fromWordPress();
        $hideSignature = $hardening->hideWordPressSignature();
        $forceSsl = $hardening->forceSslEnabled();
        $disableFileEdit = $hardening->disableFileEdit();
        $responseHeaders = $hardening->responseHeaders();
        $adminGate = $hardening->adminGateEnabled();
        $hardeningChanged = false;

        if (!empty($selected['disable_admin_gate'])) {
            $adminGate = false;
            $hardeningChanged = true;
            Hardening::clearAccessCookie();
            $actions[] = [
                'label' => __('Hidden admin URL gate', 'xerotact-shield-x-security'),
                'status' => __('Disabled', 'xerotact-shield-x-security'),
                'detail' => __('Normal wp-admin and wp-login access were restored.', 'xerotact-shield-x-security'),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Hidden admin URL gate', 'xerotact-shield-x-security'));
        }

        if (!empty($selected['disable_signature_hiding'])) {
            $hideSignature = false;
            $hardeningChanged = true;
            $actions[] = [
                'label' => __('WordPress signature hiding', 'xerotact-shield-x-security'),
                'status' => __('Disabled', 'xerotact-shield-x-security'),
                'detail' => __('Generator/header hiding was turned off for troubleshooting.', 'xerotact-shield-x-security'),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('WordPress signature hiding', 'xerotact-shield-x-security'));
        }

        if (!empty($selected['disable_force_ssl'])) {
            $forceSsl = false;
            $hardeningChanged = true;
            $actions[] = [
                'label' => __('Force SSL redirect', 'xerotact-shield-x-security'),
                'status' => __('Disabled', 'xerotact-shield-x-security'),
                'detail' => __('HTTP-to-HTTPS redirects were turned off for recovery.', 'xerotact-shield-x-security'),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Force SSL redirect', 'xerotact-shield-x-security'));
        }

        if ($hardeningChanged) {
            $hardening->save($hideSignature, $adminGate, $hardening->adminGateKey(), $forceSsl, $disableFileEdit, $responseHeaders);
        }

        if (!empty($selected['disable_hardening_rules'])) {
            $disableFileEdit = false;
            foreach (HardeningSettings::defaultResponseHeaders() as $header => $enabledByDefault) {
                if ($enabledByDefault) {
                    $responseHeaders[$header] = false;
                }
            }
            $disabledRules = new HardeningSettings([
                'hide_wordpress_signature' => $hideSignature,
                'force_ssl_enabled' => $forceSsl,
                'disable_file_edit' => $disableFileEdit,
                'response_headers' => $responseHeaders,
                'admin_gate_enabled' => $adminGate,
                'admin_gate_key' => $hardening->adminGateKey(),
                'block_xmlrpc' => false,
                'disable_pingbacks' => false,
                'disable_admin_scripts_concatenation' => false,
                'block_author_scans' => false,
                'bot_protection_enabled' => false,
                'direct_core_access_protection' => false,
                'sensitive_file_protection' => false,
                'directory_browsing_protection' => false,
                'data_php_execution_protection' => false,
            ]);
            $ruleResult = Hardening::removeWebServerRulesForEmergencyRecovery($disabledRules);
            Hardening::removeFileEditConfigForEmergencyRecovery();
            $hardening->save(
                $hideSignature,
                $adminGate,
                $hardening->adminGateKey(),
                $forceSsl,
                $disableFileEdit,
                $responseHeaders,
                [
                    'block_xmlrpc' => false,
                    'disable_pingbacks' => false,
                    'disable_admin_scripts_concatenation' => false,
                    'block_author_scans' => false,
                    'bot_protection_enabled' => false,
                    'direct_core_access_protection' => false,
                    'sensitive_file_protection' => false,
                    'directory_browsing_protection' => false,
                    'data_php_execution_protection' => false,
                ]
            );
            $actions[] = [
                'label' => __('Hardening server rules', 'xerotact-shield-x-security'),
                'status' => empty($ruleResult['ok']) ? __('Partially removed', 'xerotact-shield-x-security') : __('Removed', 'xerotact-shield-x-security'),
                'detail' => __('Removed SHIELD-X managed hardening blocks from .htaccess/data directories and removed the managed DISALLOW_FILE_EDIT block when present.', 'xerotact-shield-x-security'),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Hardening server rules', 'xerotact-shield-x-security'));
        }

        if (!empty($selected['waf_log_only']) || !empty($selected['unblock_ip'])) {
            $wafRecovery = WafSettings::fromWordPress()->emergencySafeModeForIp(
                $ip,
                !empty($selected['waf_log_only']),
                !empty($selected['unblock_ip']),
                !empty($selected['unblock_ip']),
                !empty($selected['waf_log_only'])
            );
        }

        if (!empty($selected['waf_log_only'])) {
            $actions[] = [
                'label' => __('WAF blocking mode', 'xerotact-shield-x-security'),
                'status' => __('Log only', 'xerotact-shield-x-security'),
                'detail' => sprintf(
                    /* translators: Placeholder values are generated from SHIELD-X status data, file paths, counts, or action names. */
                    __('Removed %d per-rule block overrides.', 'xerotact-shield-x-security'),
                    (int) ($wafRecovery['rule_block_overrides_removed'] ?? 0)
                ),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('WAF blocking mode', 'xerotact-shield-x-security'));
        }

        if (!empty($selected['unblock_ip'])) {
            $actions[] = [
                'label' => __('Current IP WAF blocks', 'xerotact-shield-x-security'),
                'status' => __('Unblocked', 'xerotact-shield-x-security'),
                'detail' => sprintf(
                    /* translators: 1: removed permanent IP block entries, 2: removed temporary IP blocks, 3: yes/no allowlist status. */
                    __('Removed %1$d IP block entries and %2$d temporary IP blocks. Current IP allowlisted: %3$s.', 'xerotact-shield-x-security'),
                    (int) ($wafRecovery['ip_block_entries_removed'] ?? 0),
                    (int) ($wafRecovery['temporary_blocks_removed'] ?? 0),
                    !empty($wafRecovery['ip_allowlisted']) ? __('yes', 'xerotact-shield-x-security') : __('no', 'xerotact-shield-x-security')
                ),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Current IP WAF blocks', 'xerotact-shield-x-security'));
        }

        if (!empty($selected['rate_log_only'])) {
            $rateSettings = RateLimitSettings::fromWordPress();
            $rateConfig = $rateSettings->all();
            $rateConfig['request_rate_mode'] = 'log_only';
            $rateSettings->save($rateConfig);
            $actions[] = [
                'label' => __('Request rate blocking', 'xerotact-shield-x-security'),
                'status' => __('Log only', 'xerotact-shield-x-security'),
                'detail' => __('Request-rate enforcement was changed to monitor-only mode.', 'xerotact-shield-x-security'),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Request rate blocking', 'xerotact-shield-x-security'));
        }

        if (!empty($selected['clear_lockouts'])) {
            $rateRecovery = $this->rateLimiter()->clearIp($ip);
            $actions[] = [
                'label' => __('Rate and login lockouts', 'xerotact-shield-x-security'),
                'status' => __('Cleared', 'xerotact-shield-x-security'),
                'detail' => sprintf(
                    /* translators: 1: removed request-rate state records, 2: removed login lockout records, 3: IP address. */
                    __('Removed %1$d request-rate state record and %2$d login lockout records for %3$s.', 'xerotact-shield-x-security'),
                    (int) ($rateRecovery['request_state_removed'] ?? 0),
                    (int) ($rateRecovery['login_lockouts_removed'] ?? 0),
                    (string) ($rateRecovery['ip'] ?? $ip)
                ),
            ];
        } else {
            $actions[] = $this->skippedRecoveryAction(__('Rate and login lockouts', 'xerotact-shield-x-security'));
        }

        $this->loginSecurity()->settings()->markEmergencyRecoveryUsed();
        $this->logger()->warning('emergency_recovery.completed', [
            'user_id' => $userId,
            'method' => $method,
            'ip' => $ip,
            'selection' => array_values($selection),
            'waf_ip_block_entries_removed' => (int) ($wafRecovery['ip_block_entries_removed'] ?? 0),
            'waf_temporary_blocks_removed' => (int) ($wafRecovery['temporary_blocks_removed'] ?? 0),
            'rate_request_state_removed' => (int) ($rateRecovery['request_state_removed'] ?? 0),
            'rate_login_lockouts_removed' => (int) ($rateRecovery['login_lockouts_removed'] ?? 0),
        ]);

        return [
            'ok' => true,
            'user_id' => $userId,
            'ip' => $ip,
            'method' => $method,
            'actions' => $actions,
            'admin_url' => function_exists('admin_url') ? admin_url('/') : '/wp-admin/',
        ];
    }

    /**
     * @return array{label:string,status:string,detail:string}
     */
    private function skippedRecoveryAction(string $label): array
    {
        return [
            'label' => $label,
            'status' => __('Skipped', 'xerotact-shield-x-security'),
            'detail' => __('This action was not selected.', 'xerotact-shield-x-security'),
        ];
    }

    private function createRuntimeRestorePoint(string $reason): bool
    {
        try {
            $userId = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
            $result = RestorePointManager::fromWordPress()->create($reason, [], $userId, true);
            if (!empty($result['ok'])) {
                return true;
            }

            $message = (string) ($result['message'] ?? 'Restore point creation did not complete.');
        } catch (\Throwable $error) {
            $message = $error->getMessage();
        }

        $this->logger()->warning('restore_point.pre_change_failed', [
            'reason' => $reason,
            'message' => $message,
        ]);

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function lockdownRestorePointFailure(string $operation): array
    {
        return [
            'ok' => false,
            'status' => 'restore_point_failed',
            'message' => sprintf(
                /* translators: %s is a Lockdown operation such as enable or reapply. */
                __('Lockdown %s was not performed because SHIELD-X could not create a pre-change restore point.', 'xerotact-shield-x-security'),
                $operation
            ),
        ];
    }

    /**
     * @param array<string,mixed> $result
     * @return array<string,mixed>
     */
    private function withLockdownRestorePointWarning(array $result, string $warning): array
    {
        $warning = trim($warning);
        if ($warning === '') {
            return $result;
        }

        $result['restore_point_warning'] = $warning;
        $message = trim((string) ($result['message'] ?? ''));
        $result['message'] = $message === '' ? $warning : $message . ' ' . $warning;

        return $result;
    }

    /**
     * @return array<string,array{label:string,description:string}>
     */
    private function emergencyRecoveryActions(): array
    {
        return [
            'revert_lockdown' => [
                'label' => __('Revert Lockdown filesystem changes', 'xerotact-shield-x-security'),
                'description' => __('Restores permissions and removes SHIELD-X Lockdown .htaccess stubs from the rollback manifest.', 'xerotact-shield-x-security'),
            ],
            'disable_admin_gate' => [
                'label' => __('Disable hidden admin URL gate', 'xerotact-shield-x-security'),
                'description' => __('Restores normal wp-admin and wp-login access without requiring the secret admin keyword.', 'xerotact-shield-x-security'),
            ],
            'disable_signature_hiding' => [
                'label' => __('Disable WordPress signature hiding', 'xerotact-shield-x-security'),
                'description' => __('Turns off generator/header hiding while you recover or troubleshoot the site.', 'xerotact-shield-x-security'),
            ],
            'disable_force_ssl' => [
                'label' => __('Disable Force SSL redirect', 'xerotact-shield-x-security'),
                'description' => __('Stops SHIELD-X from redirecting HTTP requests to HTTPS while you repair certificate or proxy settings.', 'xerotact-shield-x-security'),
            ],
            'disable_hardening_rules' => [
                'label' => __('Remove hardening .htaccess/config blocks', 'xerotact-shield-x-security'),
                'description' => __('Removes SHIELD-X managed hardening server rules and the managed DISALLOW_FILE_EDIT block if a host rejects those directives.', 'xerotact-shield-x-security'),
            ],
            'waf_log_only' => [
                'label' => __('Put WAF blocking in log-only mode', 'xerotact-shield-x-security'),
                'description' => __('Changes global WAF mode to monitor-only and removes per-rule block overrides.', 'xerotact-shield-x-security'),
            ],
            'unblock_ip' => [
                'label' => __('Unblock and allowlist this IP in WAF', 'xerotact-shield-x-security'),
                'description' => __('Removes matching manual/temporary WAF IP blocks and adds the current IP to trusted IPs.', 'xerotact-shield-x-security'),
            ],
            'rate_log_only' => [
                'label' => __('Put request rate blocking in log-only mode', 'xerotact-shield-x-security'),
                'description' => __('Keeps rate monitoring but disables HTTP 429 enforcement.', 'xerotact-shield-x-security'),
            ],
            'clear_lockouts' => [
                'label' => __('Clear this IP from rate/login lockouts', 'xerotact-shield-x-security'),
                'description' => __('Removes current request-rate state and login lockout records for the current IP.', 'xerotact-shield-x-security'),
            ],
        ];
    }

    private function recoveryThrottleMessage(int $retryAfter): string
    {
        $minutes = max(1, (int) ceil(max(1, $retryAfter) / 60));

        $template = function_exists('_n')
            /* translators: Placeholder values are generated from SHIELD-X status data, file paths, counts, or action names. */
            ? _n(
                'Too many recovery attempts. Try again in about %d minute.',
                'Too many recovery attempts. Try again in about %d minutes.',
                $minutes,
                'xerotact-shield-x-security'
            )
            : ($minutes === 1 ? 'Too many recovery attempts. Try again in about %d minute.' : 'Too many recovery attempts. Try again in about %d minutes.');

        return sprintf($template, $minutes);
    }

    private function renderEmergencyRecoveryStorageUnavailable(string $token): void
    {
        $this->renderEmergencyRecoveryChallenge(
            $token,
            __('Emergency recovery is temporarily unavailable. Please try again later.', 'xerotact-shield-x-security'),
            '',
            503
        );
    }

    private function createEmergencyRecoverySession(int $userId, string $ip, string $method): string
    {
        $sessionToken = TwoFactor::randomToken(32);
        $ttl = defined('MINUTE_IN_SECONDS') ? 10 * MINUTE_IN_SECONDS : 600;
        $payload = [
            'user_id' => $userId,
            'ip' => $ip,
            'method' => $method,
            'created_at' => time(),
        ];

        if (function_exists('set_transient')) {
            set_transient($this->emergencyRecoverySessionKey($sessionToken), $payload, $ttl);
        }

        return $sessionToken;
    }

    /**
     * @return array<string,mixed>
     */
    private function emergencyRecoverySession(string $sessionToken): array
    {
        $sessionToken = trim($sessionToken);

        if ($sessionToken === '' || !function_exists('get_transient')) {
            return [];
        }

        $payload = get_transient($this->emergencyRecoverySessionKey($sessionToken));

        return is_array($payload) ? $payload : [];
    }

    private function deleteEmergencyRecoverySession(string $sessionToken): void
    {
        if ($sessionToken !== '' && function_exists('delete_transient')) {
            delete_transient($this->emergencyRecoverySessionKey($sessionToken));
        }
    }

    private function emergencyRecoverySessionKey(string $sessionToken): string
    {
        return 'shieldx_emergency_' . hash('sha256', trim($sessionToken));
    }

    private function externalScanTokenFromRequest(): string
    {
        foreach (['HTTP_X_SHIELD_X_TOKEN', 'REDIRECT_HTTP_X_SHIELD_X_TOKEN'] as $key) {
            $token = $this->serverText($key);
            if ($token !== '') {
                return $token;
            }
        }

        return '';
    }

    private function tokenFromRequest(): string
    {
        $posted = $this->postedText('token');
        if ($posted !== '') {
            return $posted;
        }

        foreach ([
            'HTTP_X_SHIELDX_RECOVERY_TOKEN',
            'HTTP_X_SHIELD_X_RECOVERY_TOKEN',
            'REDIRECT_HTTP_X_SHIELDX_RECOVERY_TOKEN',
            'REDIRECT_HTTP_X_SHIELD_X_RECOVERY_TOKEN',
        ] as $key) {
            $token = $this->serverText($key);
            if ($token !== '') {
                return $token;
            }
        }

        return '';
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function isInspectionBypassRequest(array $request): bool
    {
        $uri = (string) ($request['uri'] ?? '');
        $path = self::inspectionBypassPath($uri);

        if ($path === '/wp-cron.php') {
            return true;
        }

        if (self::queryFlagExists($uri, 'shieldx_cap_site')) {
            return true;
        }

        if (in_array($path, AddonRegistry::fromWordPress()->externalPaths(), true)) {
            return true;
        }

        if (strpos($path, '/shieldx-cap/') === 0 || strpos($path, '/index.php/shieldx-cap/') === 0) {
            return true;
        }

        if (self::isStaticAssetInspectionBypassUri($uri) && (string) ($request['query_string'] ?? '') === '') {
            return true;
        }

        if (self::isWordPressUpdateRequest($path, $uri)) {
            return true;
        }

        $basename = basename($path);
        return self::isShieldXInternalAdminEndpointRequest($basename, $request);
    }

    private static function isEarlyStaticAssetBypassUri(string $uri): bool
    {
        return self::isStaticAssetInspectionBypassUri($uri);
    }

    private static function isStaticAssetInspectionBypassUri(string $uri): bool
    {
        if (!self::hasOnlySafeStaticAssetQuery($uri)) {
            return false;
        }

        $path = self::inspectionBypassPath($uri);
        $assetPath = self::inspectionAssetPath($path);
        if ($assetPath !== $path || !self::isKnownStaticAssetPath($path)) {
            return false;
        }

        return (bool) preg_match('~\.(?:css|js|map|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|mp4|mp3|pdf|zip)$~i', $assetPath);
    }

    private static function hasOnlySafeStaticAssetQuery(string $uri): bool
    {
        $separator = strpos($uri, '?');
        if ($separator === false) {
            return true;
        }

        $query = substr($uri, $separator + 1);
        if ($query === '' || strpos($query, '#') !== false) {
            return false;
        }

        $seen = [];
        $parts = explode('&', $query);
        if (count($parts) > 2) {
            return false;
        }

        foreach ($parts as $part) {
            $pair = explode('=', $part, 2);
            if (count($pair) !== 2) {
                return false;
            }

            $key = rawurldecode($pair[0]);
            $value = rawurldecode($pair[1]);
            if (!in_array($key, ['ver', 'version', 'v'], true) || isset($seen[$key])) {
                return false;
            }
            if (preg_match('/^[A-Za-z0-9._-]{1,64}$/D', $value) !== 1) {
                return false;
            }

            $seen[$key] = true;
        }

        return $seen !== [];
    }

    private static function isKnownStaticAssetPath(string $path): bool
    {
        if ($path === '/favicon.ico') {
            return true;
        }

        return preg_match('~(?:^|/)(?:wp-content/(?:uploads|plugins|themes)/|wp-includes/|wp-admin/(?:css|images|js)/)~', $path) === 1;
    }

    private static function inspectionBypassPath(string $uri): string
    {
        return strtolower(rtrim(self::urlPath($uri), '/'));
    }

    /**
     * Resolves the path segment that can execute before deciding whether a
     * request is only a static asset. A PHP-like path followed by PATH_INFO
     * must be inspected even when its trailing PATH_INFO ends in .css or .js.
     */
    private static function inspectionAssetPath(string $path): string
    {
        $path = $path !== '/' ? rtrim($path, '/') : $path;
        if (preg_match('~^(.+\.(?:php[0-9]?|phtml|phar|shtml))(?:/.*)?$~i', $path, $matches) === 1) {
            return (string) $matches[1];
        }

        return $path;
    }

    private static function isWordPressUpdateRequest(string $path, string $uri): bool
    {
        if (in_array($path, ['/wp-admin/update.php', '/wp-admin/update-core.php'], true)) {
            return true;
        }

        if (!in_array($path, ['/wp-admin/plugins.php', '/wp-admin/plugin-install.php', '/wp-admin/themes.php', '/wp-admin/theme-install.php'], true)) {
            return false;
        }

        $query = [];
        $queryString = function_exists('wp_parse_url') ? wp_parse_url($uri, PHP_URL_QUERY) : '';
        if (is_string($queryString) && $queryString !== '') {
            parse_str($queryString, $query);
        }

        foreach (['action', 'action2'] as $key) {
            $action = isset($query[$key]) && is_scalar($query[$key]) ? (string) $query[$key] : '';
            $action = strtolower((string) preg_replace('/[^a-z0-9_-]+/', '', $action));
            if (in_array($action, self::wordpressUpdateActions(), true)) {
                return true;
            }
        }

        return false;
    }

    private static function isEmergencyRecoveryRequest(string $uri): bool
    {
        $path = self::inspectionBypassPath($uri);

        if ($path === self::emergencyRecoveryPath()) {
            return true;
        }

        if ($path !== self::emergencyRecoveryFrontControllerPath()) {
            return false;
        }

        $queryPosition = strpos($uri, '?');
        if ($queryPosition === false) {
            return false;
        }

        $queryString = substr($uri, $queryPosition + 1);
        if ($queryString === '') {
            return false;
        }

        $query = [];
        parse_str($queryString, $query);

        return count($query) === 1 && isset($query['shieldx_recovery']) && (string) $query['shieldx_recovery'] === '1';
    }

    private static function emergencyRecoveryPath(): string
    {
        if (function_exists('home_url')) {
            $path = self::inspectionBypassPath((string) home_url('/shieldx-recovery'));
            if ($path !== '') {
                return $path;
            }
        }

        return '/shieldx-recovery';
    }

    private static function emergencyRecoveryFrontControllerPath(): string
    {
        if (function_exists('home_url')) {
            $path = self::inspectionBypassPath((string) home_url('/index.php'));
            if ($path !== '') {
                return $path;
            }
        }

        return '/index.php';
    }

    private function emergencyRecoveryUrl(): string
    {
        return function_exists('home_url') ? home_url('/index.php?shieldx_recovery=1') : '/index.php?shieldx_recovery=1';
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function isShieldXInternalAdminEndpointRequest(string $basename, array $request): bool
    {
        if (!in_array($basename, ['admin-post.php', 'admin-ajax.php'], true)) {
            return false;
        }

        $action = self::requestActionName($request);

        return $action !== ''
            && strpos($action, 'shieldx_') === 0
            && self::currentUserCanManageShieldX();
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function requestActionName(array $request): string
    {
        $uri = (string) ($request['uri'] ?? '');
        $action = self::queryText($uri, 'action');

        if ($action === '' && isset($request['body']) && is_scalar($request['body'])) {
            $posted = [];
            $body = (string) $request['body'];
            parse_str($body, $posted);
            $action = isset($posted['action']) && is_scalar($posted['action']) ? (string) $posted['action'] : '';
            if ($action === '') {
                $action = self::actionFromInspectionBody($body);
            }
        }

        return strtolower((string) preg_replace('/[^a-z0-9_:-]+/i', '', trim($action)));
    }

    private static function actionFromInspectionBody(string $body): string
    {
        foreach (preg_split('/\r\n|\n|\r|&/', $body) ?: [] as $line) {
            $line = trim((string) $line);
            if (stripos($line, 'action=') !== 0) {
                continue;
            }

            return substr($line, 7);
        }

        return '';
    }

    private static function queryFlagExists(string $uri, string $key): bool
    {
        return self::queryText($uri, $key) !== '';
    }

    private static function queryText(string $uri, string $key): string
    {
        $queryString = function_exists('wp_parse_url') ? wp_parse_url($uri, PHP_URL_QUERY) : '';
        if (!is_string($queryString) || $queryString === '') {
            $queryString = explode('?', explode('#', $uri, 2)[0], 2)[1] ?? '';
        }

        if ($queryString === '') {
            return '';
        }

        $query = [];
        parse_str($queryString, $query);
        $value = $query[$key] ?? '';

        return is_scalar($value) ? trim((string) $value) : '';
    }

    /**
     * @return array<int,string>
     */
    private static function wordpressUpdateActions(): array
    {
        return [
            'upgrade-plugin',
            'update-plugin',
            'update-selected',
            'install-plugin',
            'upload-plugin',
            'activate-plugin',
            'upgrade-theme',
            'update-theme',
            'update-selected-themes',
            'install-theme',
            'upload-theme',
            'do-core-upgrade',
            'upgrade-core',
            'update-core',
        ];
    }

    private static function urlPath(string $uri): string
    {
        if (function_exists('wp_parse_url')) {
            $parsed = wp_parse_url($uri);
            $path = is_array($parsed) ? ($parsed['path'] ?? '') : '';

            return RequestPath::decodeTwice(is_string($path) && $path !== '' ? $path : '/');
        }

        $withoutFragment = explode('#', $uri, 2)[0];
        $path = explode('?', $withoutFragment, 2)[0];
        if (preg_match('~^[a-z][a-z0-9+.-]*://~i', $path)) {
            $withoutScheme = preg_replace('~^[a-z][a-z0-9+.-]*://~i', '', $path) ?: $path;
            $slash = strpos($withoutScheme, '/');
            $path = $slash === false ? '/' : substr($withoutScheme, $slash);
        }

        return RequestPath::decodeTwice($path === '' ? '/' : $path);
    }

    private function postedText(string $key): string
    {
        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Emergency recovery reads a signed token from POST before a normal WordPress admin nonce exists.
        if (!isset($_POST[$key]) || !is_scalar($_POST[$key])) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return '';
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing -- Dynamic signed-token value is unslashed here and sanitized before return.
        $value = function_exists('wp_unslash') ? wp_unslash($_POST[$key]) : $_POST[$key];
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        $value = is_scalar($value) ? (string) $value : '';

        return trim(function_exists('sanitize_text_field') ? sanitize_text_field($value) : $value);
    }

    /**
     * @return array<int,string>
     */
    private function postedRecoveryActions(string $sessionToken): array
    {
        if (!$this->recoveryActionNonceValid($sessionToken)) {
            return [];
        }

        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Recovery action choices are accepted only after the signed recovery token, 2FA challenge, IP-bound recovery session, and recovery action nonce have been validated.
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Recovery action array is allowlisted below and each value is unslashed before use.
        $posted = isset($_POST['shieldx_recovery_actions']) ? $_POST['shieldx_recovery_actions'] : [];
        // phpcs:enable WordPress.Security.NonceVerification.Missing
        $posted = is_array($posted) ? $posted : [];
        $allowed = array_keys($this->emergencyRecoveryActions());
        $selected = [];

        foreach ($posted as $value) {
            if (!is_scalar($value)) {
                continue;
            }

            $action = trim((string) (function_exists('wp_unslash') ? wp_unslash($value) : $value));
            $action = function_exists('sanitize_key') ? sanitize_key($action) : strtolower((string) preg_replace('/[^a-z0-9_-]+/', '', $action));
            if (in_array($action, $allowed, true)) {
                $selected[$action] = $action;
            }
        }

        return array_values($selected);
    }

    private function recoveryActionNonceValid(string $sessionToken): bool
    {
        $sessionToken = trim($sessionToken);
        if ($sessionToken === '' || !function_exists('wp_verify_nonce')) {
            return false;
        }

        $nonce = $this->postedText('shieldx_recovery_nonce');

        return $nonce !== '' && wp_verify_nonce($nonce, $this->recoveryActionNonceAction($sessionToken)) !== false;
    }

    private function recoveryActionNonceAction(string $sessionToken): string
    {
        return 'shieldx_recovery_actions_' . hash('sha256', trim($sessionToken));
    }

    private function serverText(string $key, string $default = ''): string
    {
        if (!isset($_SERVER[$key]) || !is_scalar($_SERVER[$key])) {
            return $default;
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Dynamic server value is unslashed here and sanitized before return.
        $value = function_exists('wp_unslash') ? wp_unslash($_SERVER[$key]) : $_SERVER[$key];
        $value = is_scalar($value) ? (string) $value : $default;

        return function_exists('sanitize_text_field') ? sanitize_text_field($value) : trim($value);
    }

    private function maybeUpgradeSchema(): void
    {
        $installer = SchemaInstaller::fromWordPress();

        if ($installer->installedVersion() !== SchemaInstaller::SCHEMA_VERSION) {
            $installer->install();
        }
    }

    /**
     * @param array<string,mixed> $rateLimit
     */
    private function blockRateLimitedRequest(array $rateLimit): void
    {
        $retryAfter = max(1, (int) ($rateLimit['retry_after'] ?? 1));

        if (!headers_sent()) {
            header('Retry-After: ' . (string) $retryAfter);
        }

        if (function_exists('status_header')) {
            status_header(429);
        } else {
            http_response_code(429);
        }
        self::sendNoStoreHeaders();

        $message = __('Too many requests. Please slow down and try again shortly.', 'xerotact-shield-x-security');

        if (function_exists('wp_die')) {
            wp_die(esc_html($message), esc_html__('Rate limited', 'xerotact-shield-x-security'), ['response' => 429]);
        }

        echo esc_html($message);
        exit;
    }

    /**
     * @param array<string,mixed> $context
     */
    private function blockWafRequest(array $context): void
    {
        if (function_exists('status_header')) {
            status_header(403);
        } else {
            http_response_code(403);
        }
        self::sendNoStoreHeaders();

        if (!headers_sent()) {
            header('X-Content-Type-Options: nosniff');
        }

        $message = __('SHIELD-X blocked this request.', 'xerotact-shield-x-security');

        if (function_exists('wp_die')) {
            wp_die(esc_html($message), esc_html__('Request blocked', 'xerotact-shield-x-security'), ['response' => 403]);
        }

        echo esc_html($message);
        exit;
    }

    private static function sendNoStoreHeaders(): void
    {
        if (function_exists('nocache_headers')) {
            nocache_headers();
        }

        if (!headers_sent()) {
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            header('Expires: Wed, 11 Jan 1984 05:00:00 GMT');
        }
    }

    private function renderEmergencyRecoveryTokenEntry(): void
    {
        $this->sendHtmlStatus(200);
        $siteName = function_exists('get_bloginfo') ? (string) get_bloginfo('name') : 'WordPress';
        ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html__('SHIELD-X Emergency Recovery', 'xerotact-shield-x-security'); ?></title>
    <?php $this->renderEmergencyRecoveryCss(); ?>
</head>
<body class="shieldx-recovery-page">
    <main class="shieldx-recovery-card">
        <p class="shieldx-recovery-kicker"><?php echo esc_html($siteName !== '' ? $siteName : 'WordPress'); ?></p>
        <h1><?php echo esc_html__('SHIELD-X Emergency Recovery', 'xerotact-shield-x-security'); ?></h1>
        <p><?php echo esc_html__('Enter the recovery token from SHIELD-X. It is submitted securely and is never added to the URL.', 'xerotact-shield-x-security'); ?></p>
        <form method="post" action="<?php echo esc_url($this->emergencyRecoveryUrl()); ?>" autocomplete="off">
            <input type="hidden" name="shieldx_recovery_step" value="token">
            <label>
                <span><?php echo esc_html__('Recovery token', 'xerotact-shield-x-security'); ?></span>
                <input type="password" name="token" value="" autocomplete="off" autocapitalize="none" spellcheck="false" required>
            </label>
            <button type="submit"><?php echo esc_html__('Continue', 'xerotact-shield-x-security'); ?></button>
        </form>
    </main>
</body>
</html>
        <?php
        exit;
    }

    private function renderEmergencyRecoveryChallenge(string $token, string $error = '', string $identifier = '', int $status = 200): void
    {
        $this->sendHtmlStatus($status);
        $siteName = function_exists('get_bloginfo') ? (string) get_bloginfo('name') : 'WordPress';
        ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html__('SHIELD-X Emergency Recovery', 'xerotact-shield-x-security'); ?></title>
    <?php $this->renderEmergencyRecoveryCss(); ?>
</head>
<body class="shieldx-recovery-page">
    <main class="shieldx-recovery-card">
        <p class="shieldx-recovery-kicker"><?php echo esc_html($siteName !== '' ? $siteName : 'WordPress'); ?></p>
        <h1><?php echo esc_html__('SHIELD-X Emergency Recovery', 'xerotact-shield-x-security'); ?></h1>
        <p><?php echo esc_html__('Enter an administrator username or email and a valid SHIELD-X authenticator or recovery code. Recovery options appear after this code is accepted.', 'xerotact-shield-x-security'); ?></p>
        <?php if ($error !== '') : ?>
            <div class="shieldx-recovery-error"><?php echo esc_html($error); ?></div>
        <?php endif; ?>
        <form method="post" action="<?php echo esc_url($this->emergencyRecoveryUrl()); ?>">
            <input type="hidden" name="token" value="<?php echo esc_attr($token); ?>">
            <label>
                <span><?php echo esc_html__('Administrator username or email', 'xerotact-shield-x-security'); ?></span>
                <input type="text" name="shieldx_admin_identifier" value="<?php echo esc_attr($identifier); ?>" autocomplete="username" required>
            </label>
            <label>
                <span><?php echo esc_html__('Authenticator or recovery code', 'xerotact-shield-x-security'); ?></span>
                <input type="text" name="shieldx_totp_code" value="" autocomplete="one-time-code" required>
            </label>
            <button type="submit"><?php echo esc_html__('Verify 2FA', 'xerotact-shield-x-security'); ?></button>
        </form>
    </main>
</body>
</html>
        <?php
        exit;
    }

    /**
     * @param array<string,mixed> $session
     */
    private function renderEmergencyRecoveryOptions(string $token, string $sessionToken, array $session, string $error = '', int $status = 200): void
    {
        $this->sendHtmlStatus($status);
        $actions = $this->emergencyRecoveryActions();
        $recoveryNonce = function_exists('wp_create_nonce') ? wp_create_nonce($this->recoveryActionNonceAction($sessionToken)) : '';
        ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html__('Choose SHIELD-X Recovery Actions', 'xerotact-shield-x-security'); ?></title>
    <?php $this->renderEmergencyRecoveryCss(); ?>
</head>
<body class="shieldx-recovery-page">
    <main class="shieldx-recovery-card">
        <p class="shieldx-recovery-kicker"><?php echo esc_html__('2FA verified', 'xerotact-shield-x-security'); ?></p>
        <h1><?php echo esc_html__('Choose what to recover', 'xerotact-shield-x-security'); ?></h1>
        <p><?php echo esc_html__('Select only the protections you want SHIELD-X to revert or disable. All actions are selected by default for a full safe-mode recovery.', 'xerotact-shield-x-security'); ?></p>
        <?php if ($error !== '') : ?>
            <div class="shieldx-recovery-error"><?php echo esc_html($error); ?></div>
        <?php endif; ?>
        <form method="post" action="<?php echo esc_url($this->emergencyRecoveryUrl()); ?>">
            <input type="hidden" name="token" value="<?php echo esc_attr($token); ?>">
            <input type="hidden" name="shieldx_recovery_step" value="apply">
            <input type="hidden" name="shieldx_recovery_session" value="<?php echo esc_attr($sessionToken); ?>">
            <input type="hidden" name="shieldx_recovery_nonce" value="<?php echo esc_attr($recoveryNonce); ?>">
            <div class="shieldx-recovery-options">
                <?php foreach ($actions as $key => $action) : ?>
                    <label class="shieldx-recovery-option">
                        <input type="checkbox" name="shieldx_recovery_actions[]" value="<?php echo esc_attr($key); ?>" checked>
                        <span>
                            <strong><?php echo esc_html((string) ($action['label'] ?? $key)); ?></strong>
                            <small><?php echo esc_html((string) ($action['description'] ?? '')); ?></small>
                        </span>
                    </label>
                <?php endforeach; ?>
            </div>
            <button type="submit"><?php echo esc_html__('Apply Selected Recovery Actions', 'xerotact-shield-x-security'); ?></button>
        </form>
    </main>
</body>
</html>
        <?php
        exit;
    }

    /**
     * @param array<string,mixed> $result
     */
    private function renderEmergencyRecoveryResult(array $result): void
    {
        $this->sendHtmlStatus(200);
        $actions = isset($result['actions']) && is_array($result['actions']) ? $result['actions'] : [];
        ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html__('SHIELD-X Recovery Complete', 'xerotact-shield-x-security'); ?></title>
    <?php $this->renderEmergencyRecoveryCss(); ?>
</head>
<body class="shieldx-recovery-page">
    <main class="shieldx-recovery-card">
        <p class="shieldx-recovery-kicker"><?php echo esc_html__('Recovery complete', 'xerotact-shield-x-security'); ?></p>
        <h1><?php echo esc_html__('Safe working mode restored', 'xerotact-shield-x-security'); ?></h1>
        <p><?php echo esc_html__('SHIELD-X reverted protective changes that could block the owner from accessing WordPress.', 'xerotact-shield-x-security'); ?></p>
        <dl class="shieldx-recovery-actions">
            <?php foreach ($actions as $action) : ?>
                <?php if (!is_array($action)) { continue; } ?>
                <dt><?php echo esc_html((string) ($action['label'] ?? '')); ?></dt>
                <dd>
                    <strong><?php echo esc_html((string) ($action['status'] ?? '')); ?></strong>
                    <span><?php echo esc_html((string) ($action['detail'] ?? '')); ?></span>
                </dd>
            <?php endforeach; ?>
        </dl>
        <a class="shieldx-recovery-button" href="<?php echo esc_url((string) ($result['admin_url'] ?? '/wp-admin/')); ?>"><?php echo esc_html__('Open WordPress Admin', 'xerotact-shield-x-security'); ?></a>
    </main>
</body>
</html>
        <?php
        exit;
    }

    private function sendHtmlStatus(int $status): void
    {
        if (function_exists('status_header')) {
            status_header($status);
        } else {
            http_response_code($status);
        }

        if (function_exists('nocache_headers')) {
            nocache_headers();
        }

        if (!headers_sent()) {
            header('Content-Type: text/html; charset=utf-8', true, $status);
        }
    }

    private function renderEmergencyRecoveryCss(): void
    {
        if (!function_exists('wp_register_style') || !function_exists('wp_add_inline_style') || !function_exists('wp_print_styles')) {
            return;
        }

        wp_register_style('shieldx-recovery-page', false, [], defined('SHIELDX_VERSION') ? (string) SHIELDX_VERSION : '1.0.0');
        wp_add_inline_style('shieldx-recovery-page', 'body.shieldx-recovery-page{background:#f0f0f1;color:#1d2327;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}.shieldx-recovery-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;box-shadow:0 16px 40px rgba(0,0,0,.08);max-width:560px;padding:28px;width:100%}.shieldx-recovery-kicker{color:#2271b1;font-size:12px;font-weight:700;letter-spacing:0;margin:0 0 8px;text-transform:uppercase}.shieldx-recovery-card h1{font-size:26px;line-height:1.2;margin:0 0 10px}.shieldx-recovery-card p{color:#50575e;line-height:1.55;margin:0 0 18px}.shieldx-recovery-card form{display:grid;gap:14px}.shieldx-recovery-card label span{display:block;font-weight:700;margin-bottom:6px}.shieldx-recovery-card input{border:1px solid #8c8f94;border-radius:4px;font-size:16px;min-height:44px;padding:8px 10px;width:100%;box-sizing:border-box}.shieldx-recovery-options{display:grid;gap:10px}.shieldx-recovery-option{align-items:flex-start;border:1px solid #dcdcde;border-radius:6px;display:grid;gap:10px;grid-template-columns:20px minmax(0,1fr);padding:12px}.shieldx-recovery-option input[type=checkbox]{height:18px;margin:1px 0 0;min-height:0;padding:0;width:18px}.shieldx-recovery-option span{display:block;margin:0}.shieldx-recovery-option small{color:#50575e;display:block;font-weight:400;line-height:1.45;margin-top:4px}.shieldx-recovery-card button,.shieldx-recovery-button{background:#2271b1;border:0;border-radius:4px;color:#fff;display:inline-flex;font-size:15px;font-weight:700;justify-content:center;min-height:42px;padding:11px 16px;text-decoration:none}.shieldx-recovery-error{background:#fcf0f1;border:1px solid #d63638;border-radius:6px;color:#5f1718;margin-bottom:14px;padding:10px 12px}.shieldx-recovery-actions{border-top:1px solid #f0f0f1;margin:18px 0;padding-top:12px}.shieldx-recovery-actions dt{font-weight:700;margin-top:12px}.shieldx-recovery-actions dd{margin:4px 0 0}.shieldx-recovery-actions dd strong{display:block}.shieldx-recovery-actions dd span{color:#50575e;display:block;line-height:1.45;margin-top:2px}');
        wp_print_styles(['shieldx-recovery-page']);
    }
}
