# 404-solution/4.1.19/includes/WordPress_Connector.php

404 Solution, version 4.1.19. 1,492 lines.

- Page: https://pluginprobe.com/plugins/404-solution/4.1.19/code/includes/WordPress_Connector.php
- Raw: https://pluginprobe.com/plugins/404-solution/4.1.19/raw/includes/WordPress_Connector.php
- Modified: 2026-05-18T16:35:10+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/404-solution/4.1.19/code/includes/WordPress_Connector.php#L10-L20`.

```php
<?php


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

/* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc).  */

class ABJ_404_Solution_WordPress_Connector {

	/** @var self|null */
	private static $instance = null;
    private const REVIEW_INITIAL_DELAY_DAYS = 30;
    private const REVIEW_ASK_LATER_DELAY_DAYS = 7;
    private const REVIEW_CLOSE_X_SNOOZE_DAYS = 14;

    /** Set to true by handleReviewResponseRedirects() when feedback POST is processed,
     * so maybeShowReviewRequest() can display the thank-you notice instead of the form.
     * @var bool */
    private static $feedbackSubmitted = false;

    /** @var array<int, string> */
    private static $adminRuntimeErrors = array();

	/** @var ABJ_404_Solution_PluginLogic */
	private $logic;

	/** @var ABJ_404_Solution_DataAccess */
	private $dao;

	/** @var ABJ_404_Solution_Logging */
	private $logger;

	/** @var ABJ_404_Solution_Functions */
	private $f;

	/** @var ABJ_404_Solution_SpellChecker */
	private $spellChecker;

	/** @var ABJ_404_Solution_FrontendRequestPipeline|null */
	private $frontendPipeline = null;

	/**
	 * Constructor with dependency injection.
	 *
	 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
	 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer
	 * @param ABJ_404_Solution_Logging|null $logging Logging service
	 * @param ABJ_404_Solution_Functions|null $functions String utilities
	 * @param ABJ_404_Solution_SpellChecker|null $spellChecker Spell checker service
	 */
	public function __construct($pluginLogic = null, $dataAccess = null, $logging = null, $functions = null, $spellChecker = null) {
		// Use injected dependencies or fall back to getInstance() for backward compatibility
		$this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
		$this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
		$this->logger = $logging !== null ? $logging : abj_service('logging');
		$this->f = $functions !== null ? $functions : abj_service('functions');
		$this->spellChecker = $spellChecker !== null ? $spellChecker : abj_service('spell_checker');
	}

	/** @return ABJ_404_Solution_FrontendRequestPipeline */
	private function getFrontendPipeline() {
		if ($this->frontendPipeline !== null) {
			return $this->frontendPipeline;
		}

		if (!class_exists('ABJ_404_Solution_FrontendRequestPipeline')) {
			require_once dirname(__FILE__) . '/FrontendRequestPipeline.php';
		}

		$matchingEngines = [];
		if (class_exists('ABJ_404_Solution_ServiceContainer')) {
			$engines = ABJ_404_Solution_ServiceContainer::safeGet('matching_engines');
			if (is_array($engines)) {
				$matchingEngines = $engines;
			}
		}

		$this->frontendPipeline = new ABJ_404_Solution_FrontendRequestPipeline(
			$this->logic,
			$this->dao,
			$this->logger,
			$this->f,
			$this->spellChecker,
			$matchingEngines
		);
		return $this->frontendPipeline;
	}

	/** @return self */
	public static function getInstance() {
		if (self::$instance !== null) {
			return self::$instance;
		}

		// If the DI container is initialized, prefer it.
		if (class_exists('ABJ_404_Solution_ServiceContainer')) {
			$svc = ABJ_404_Solution_ServiceContainer::safeGet('wordpress_connector');
			if ($svc instanceof self) {
				self::$instance = $svc;
				return self::$instance;
			}
		}

		self::$instance = new ABJ_404_Solution_WordPress_Connector();

		return self::$instance;
	}

    /**
     * Persist and queue an admin runtime error so users see a notice instead of a blank page.
     *
     * @param string $hookName
     * @param Throwable $e
     * @return void
     */
    private static function reportAdminRuntimeError(string $hookName, Throwable $e): void {
        $summary = sprintf('[%s] %s', $hookName, $e->getMessage());
        self::$adminRuntimeErrors[] = $summary;

        try {
            $logger = abj_service('logging');
            $logger->errorMessage('Admin runtime exception in ' . $hookName . ': ' . $e->getMessage());
        } catch (Throwable $ignored) {
            // Last-resort logging fallback.
            @error_log('404 Solution admin runtime exception in ' . $hookName . ': ' . $e->getMessage());
        }

        if (function_exists('set_transient')) {
            set_transient('abj404_admin_runtime_error', $summary, 300);
        }
    }

    /**
     * Echo one-time admin runtime errors captured from earlier hooks in this request (or previous request).
     *
     * @return void
     */
    private static function echoAdminRuntimeErrorNotice(): void {
        $errors = self::$adminRuntimeErrors;
        self::$adminRuntimeErrors = array();

        if (function_exists('get_transient')) {
            $saved = get_transient('abj404_admin_runtime_error');
            if (is_string($saved) && $saved !== '') {
                $errors[] = $saved;
                delete_transient('abj404_admin_runtime_error');
            }
        }

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

        $message = implode("\n", array_unique(array_filter($errors)));
        echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ';
        echo esc_html__('An internal error occurred while loading this admin page.', '404-solution');
        echo '</p><details><summary>' . esc_html__('Show details', '404-solution') . '</summary><pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">';
        echo esc_html($message);
        echo '</pre></details></div>';
    }
	
	/** Setup.
	 * @return void
	 */
    static function init() {
        self::registerLifecycleHooks();
        self::registerAdminHooks();
        self::registerAsyncSuggestionHooks();
        ABJ_404_Solution_PluginLogic::doRegisterCrons();
    }

    /** @return void */
    private static function registerLifecycleHooks() {
        if (!is_admin()) {
            return;
        }

        register_deactivation_hook(ABJ404_NAME, 'ABJ_404_Solution_PluginLogic::runOnPluginDeactivation');
        register_activation_hook(ABJ404_NAME, 'ABJ_404_Solution_PluginLogic::runOnPluginActivation');

        if (is_multisite()) {
            add_action('wpmu_new_blog', 'ABJ_404_Solution_PluginLogic::activateNewSite', 10, 6);
            add_action('wp_initialize_site', 'ABJ_404_Solution_PluginLogic::activateNewSiteModern', 10, 2);
            add_action('delete_blog', 'ABJ_404_Solution_PluginLogic::deleteBlogData', 10, 2);
        }
    }

    /** @return void */
    private static function registerAdminHooks() {
        if (!is_admin()) {
            return;
        }

        add_filter("plugin_action_links_" . ABJ404_NAME,
            'ABJ_404_Solution_WordPress_Connector::addSettingsLinkToPluginPage');
        add_filter('plugin_row_meta',
            'ABJ_404_Solution_WordPress_Connector::addPluginRowMeta', 10, 2);
        add_action('admin_notices',
            'ABJ_404_Solution_WordPress_Connector::echoDashboardNotification');
        add_action('admin_init',
            'ABJ_404_Solution_WordPress_Connector::handleReviewResponseRedirects');
        add_action('admin_menu',
            'ABJ_404_Solution_WordPress_Connector::addMainSettingsPageLink');
        add_action('admin_enqueue_scripts',
            'ABJ_404_Solution_WordPress_Connector::add_scripts', 11);
        add_action('admin_enqueue_scripts',
            'ABJ_404_Solution_WordPress_Connector::enqueueSupportRequestAssetsOnPluginsPage', 11);
        add_action('admin_head',
            'ABJ_404_Solution_WordPress_Connector::outputCriticalThemeCSS', 1);

        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_echoViewLogsFor', 'ABJ_404_Solution_Ajax_Php::echoViewLogsFor');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_trashLink', 'ABJ_404_Solution_Ajax_TrashLink::trashAction');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_echoRedirectToPages', 'ABJ_404_Solution_Ajax_Php::echoRedirectToPages');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_updateOptions', 'ABJ_404_Solution_Ajax_Php::updateOptions');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_load_gsc_section', 'ABJ_404_Solution_Ajax_Php::loadGscSection');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404getTrendData', 'ABJ_404_Solution_Ajax_TrendData::echoTrendData');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_crossPluginPreview', 'ABJ_404_Solution_Ajax_CrossPluginImporter::handlePreview');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_gsc_oauth_callback', 'ABJ_404_Solution_WordPress_Connector::handleGscOauthCallback');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_gsc_revoke', 'ABJ_404_Solution_WordPress_Connector::handleGscRevoke');

        ABJ_404_Solution_Ajax_EngineProfiles::registerActions();
        ABJ_404_Solution_Ajax_SettingsModeToggle::init();
        ABJ_404_Solution_Ajax_RestoreDefaults::init();
        ABJ_404_Solution_Ajax_SupportRequest::init();
        ABJ_404_Solution_Ajax_SupportRequestPreview::init();
        ABJ_404_Solution_UninstallModal::init();
        ABJ_404_Solution_SetupWizard::init();
        if (class_exists('ABJ_404_Solution_Privacy')) {
            ABJ_404_Solution_Privacy::init();
        }
    }

    /** @return void */
    private static function registerAsyncSuggestionHooks() {
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_compute_suggestions', 'ABJ_404_Solution_Ajax_SuggestionCompute::computeSuggestions');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_nopriv_abj404_compute_suggestions', 'ABJ_404_Solution_Ajax_SuggestionCompute::computeSuggestions');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_poll_suggestions', 'ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions');
        ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_nopriv_abj404_poll_suggestions', 'ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions');
    }

    /** Include things necessary for ajax.
     * @param string $hook
     * @return void
     */
    static function add_scripts($hook) {
        // only load this stuff for this plugin. 
        // thanks to https://pippinsplugins.com/loading-scripts-correctly-in-the-wordpress-admin/
        if (!array_key_exists('abj404_settingsPageName', $GLOBALS) ||
                $hook != $GLOBALS['abj404_settingsPageName']) {
            return;
        }

        try {
            $subpage = '';
            if (array_key_exists('subpage', $_GET)) {
                $subpage = sanitize_text_field(self::normalizeRequestScalar($_GET['subpage']));
            }
            // Default plugin landing is redirects when subpage is not specified.
            if ($subpage === '') {
                $subpage = 'abj404_redirects';
            }

            $isOptionsPage = ($subpage === 'abj404_options');
            $isStatsPage = ($subpage === 'abj404_stats');
            $isToolsPage = ($subpage === 'abj404_tools');
            $isCardAccordionPage = in_array($subpage, array('abj404_options', 'abj404_tools', 'abj404_stats'), true);
            $isLogsPage = ($subpage === 'abj404_logs');
            $isListPage = in_array($subpage, array('abj404_redirects', 'abj404_captured', 'abj404_logs'), true);
            $isEditPage = ($subpage === 'abj404_edit');
            $needsDestinationAutocomplete = in_array($subpage, array('abj404_redirects', 'abj404_captured', 'abj404_options', 'abj404_edit'), true);

        // remove the "thank you for creating with wordpress" message
        add_filter('admin_footer_text',
            'ABJ_404_Solution_WordPress_Connector::remove_admin_footer_text');
        // remove the version number message
        add_filter('update_footer',
            'ABJ_404_Solution_WordPress_Connector::remove_admin_footer_text', 11);
        
        // jquery is used for the searchable dropdown list of pages for adding a redirect and other things.
        ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery');
		ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-ui-autocomplete');
		ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-effects-core');
		ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-effects-highlight');
		ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-color');
        
        wp_register_script('abj404-redirect_to_ajax', plugin_dir_url(__FILE__) . 'ajax/redirect_to_ajax.js', 
                array('jquery', 'jquery-ui-autocomplete'));
        wp_register_script('abj404-exclude_pages_ajax', plugin_dir_url(__FILE__) . 'ajax/exclude_pages_ajax.js',
        	array('jquery', 'jquery-ui-autocomplete', 'abj404-redirect_to_ajax'));
        // Localize the script with new data
        $translation_array = array(
            'type_a_page_name' => __('(Type a page name or an external URL)', '404-solution'),
            'a_page_has_been_selected' => __('(A page has been selected.)', '404-solution'),
            'an_external_url_will_be_used' => __('(An external URL will be used.)', '404-solution')
        );
        wp_localize_script('abj404-redirect_to_ajax', 'abj404localization', $translation_array );        
        if ($needsDestinationAutocomplete) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-redirect_to_ajax');
            wp_localize_script('abj404-exclude_pages_ajax', 'abj404localization', $translation_array );
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-exclude_pages_ajax');
        }
        
        // make sure the "apply" button is only enabled if at least one checkbox is selected
        wp_register_script('abj404-enable_disable_apply_button_js', 
                ABJ404_URL . 'includes/js/enableDisableApplyButton.js');
        $translation_array = array('{altText}' => __('Choose at least one URL', '404-solution'));
        wp_localize_script('abj404-enable_disable_apply_button_js', 'abj404localization', $translation_array);
        if ($isListPage) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-enable_disable_apply_button_js');
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-trash_link_ajax', plugin_dir_url(__FILE__) . 'ajax/trash_link_ajax.js',
                    array('jquery'));
        }
        // tableInteractions.js provides abj404ToggleRegexInfo() used on both list pages
        // and the Edit Redirect page (subpage=abj404_edit).
        if ($isListPage || $isEditPage) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-table-interactions', plugin_dir_url(__FILE__) . 'js/tableInteractions.js',
                    array('jquery'));

            // Localized strings for time-ago display
            wp_localize_script('abj404-table-interactions', 'abj404_time_ago', array(
                'second'  => __('second', '404-solution'),
                'seconds' => __('seconds', '404-solution'),
                'minute'  => __('minute', '404-solution'),
                'minutes' => __('minutes', '404-solution'),
                'hour'    => __('hour', '404-solution'),
                'hours'   => __('hours', '404-solution'),
                'day'     => __('day', '404-solution'),
                'days'    => __('days', '404-solution'),
                'ago'     => __('ago', '404-solution'),
            ));
        }

        if ($isListPage || $isStatsPage) {
            self::enqueueViewUpdaterModules(plugin_dir_url(__FILE__) . 'ajax/');
        }

        if ($isLogsPage) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-search_logs_ajax', plugin_dir_url(__FILE__) . 'ajax/search_logs_ajax.js',
                array('jquery', 'jquery-ui-autocomplete'));
        }

        if ($isOptionsPage) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-general-js', plugin_dir_url(__FILE__) . 'js/general.js',
                array('jquery'));

            // Localize general.js strings for translation
            wp_localize_script('abj404-general-js', 'abj404General', array(
                'savingSettings' => __('Saving settings...', '404-solution'),
            ));

            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-theme-preview', plugin_dir_url(__FILE__) . 'js/themePreview.js',
                array('jquery'));

            // Settings mode toggle (Simple/Advanced)
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-settings-mode-toggle', plugin_dir_url(__FILE__) . 'ajax/SettingsModeToggle.js',
                array('jquery'));

            // Restore defaults (sticky save bar)
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-restore-defaults', plugin_dir_url(__FILE__) . 'ajax/RestoreDefaults.js',
                array('jquery'));

            // Behavior tiles (404 destination selector)
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-behavior-tiles', ABJ404_URL . 'includes/js/behaviorTiles.js',
                array());
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-settings-deferred', ABJ404_URL . 'includes/js/settingsDeferred.js',
                array('jquery'));
        }

        if ($isCardAccordionPage) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-options-accordion', plugin_dir_url(__FILE__) . 'js/optionsAccordion.js',
                array('jquery'));

            // Localize accordion strings for translation
            wp_localize_script('abj404-options-accordion', 'abj404Accordion', array(
                'expandAll' => __('Expand All', '404-solution'),
                'collapseAll' => __('Collapse All', '404-solution'),
            ));
        }

        if ($isOptionsPage) {
            ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-engine-profiles',
                plugin_dir_url(__FILE__) . 'ajax/ajax-engine-profiles.js',
                array('jquery'));
            wp_localize_script('abj404-engine-profiles', 'abj404EngineProfiles', array(
                'nonce'   => wp_create_nonce('abj404_engine_profiles_nonce'),
                'ajaxUrl' => admin_url('admin-ajax.php'),
                'i18n'    => array(
                    'edit'            => __('Edit', '404-solution'),
                    'delete'          => __('Delete', '404-solution'),
                    'addProfile'      => __('Add Engine Profile', '404-solution'),
                    'editProfile'     => __('Edit Engine Profile', '404-solution'),
                    'nameRequired'    => __('Profile name is required.', '404-solution'),
                    'patternRequired' => __('URL pattern is required.', '404-solution'),
                    'saved'           => __('Profile saved.', '404-solution'),
                    'saveFailed'      => __('Failed to save profile.', '404-solution'),
                    'confirmDelete'   => __('Delete this engine profile?', '404-solution'),
                ),
            ));
        }

        ABJ_404_Solution_WPUtils::my_wp_enq_scrpt(
            'abj404-review-feedback',
            plugin_dir_url(__FILE__) . 'js/reviewFeedback.js',
            array()
        );

        self::registerSupportRequestAssets();

        ABJ_404_Solution_WPUtils::my_wp_enq_style('abj404solution-styles', ABJ404_URL . 'includes/html/404solutionStyles.css',
                array());
        ABJ_404_Solution_WPUtils::my_wp_enq_style('abj404solution-themes', ABJ404_URL . 'includes/html/adminThemes.css',
                array());

            // Load RTL styles for Arabic, Hebrew, and other right-to-left languages
            if (is_rtl()) {
                ABJ_404_Solution_WPUtils::my_wp_enq_style('abj404solution-rtl', ABJ404_URL . 'includes/html/404solutionStyles-rtl.css',
                        array('abj404solution-styles'));
            }
        } catch (Throwable $e) {
            self::reportAdminRuntimeError('admin_enqueue_scripts', $e);
        }
    }

    /**
     * Enqueue the reusable support-request button assets. Loaded on
     * every plugin admin page so any screen can drop a
     * SupportRequestButton::render() mount-point without a per-screen
     * enqueue checklist that drifts as new mount points are added.
     *
     * The inline bootstrap exposes window.ABJ404.ajaxurl plus
     * window.ABJ404.nonces.{support_request, support_request_preview}
     * so the JS client and the modal component can both reach the
     * nonces without wp_localize_script's per-handle binding.
     *
     * @return void
     */
    /**
     * Enqueue the support-request button assets specifically for the
     * wp-admin/plugins.php screen so the row-meta link added by
     * `addPluginRowMeta()` can open its consent modal in-place. The
     * plugin's main `add_scripts()` enqueue is gated to the plugin's
     * settings page and would skip plugins.php otherwise.
     *
     * Scope: this hook runs on every admin page but no-ops unless the
     * current screen is plugins.php, keeping the asset footprint tight.
     *
     * @param string $hook the admin page slug WP passes to admin_enqueue_scripts
     * @return void
     */
    static function enqueueSupportRequestAssetsOnPluginsPage($hook) {
        if ($hook !== 'plugins.php') {
            return;
        }
        try {
            self::registerSupportRequestAssets();
        } catch (Throwable $e) {
            self::reportAdminRuntimeError('admin_enqueue_scripts:plugins.php', $e);
        }
    }

    /**
     * Enqueue the view-updater module bundle (the AJAX-driven admin table
     * orchestration). Split out of add_scripts() to keep that function under
     * the ModularityTest body-line cap. Enqueue order matters: every file
     * below uses globals defined by the modules listed before it; the
     * bootstrap (view_updater.js) declares the jQuery.ready entry point and
     * must load LAST so the helpers are defined when ready fires.
     * WordPress's $deps array enforces this ordering on the emitted
     * <script> tags. The B20 nonce-refresh helper exposes
     * abj404AjaxWithNonceRetry which every sibling uses via a soft typeof
     * reference, so its enqueue must precede them.
     *
     * @param string $vuBase URL prefix for the ajax/ assets directory.
     * @return void
     */
    private static function enqueueViewUpdaterModules(string $vuBase): void {
        $enq = array('ABJ_404_Solution_WPUtils', 'my_wp_enq_scrpt');
        $enq('abj404-view-updater-nonce-refresh',
            $vuBase . 'view_updater_nonce_refresh.js', array('jquery'));
        $enq('abj404-view-updater-stage-diagnostics',
            $vuBase . 'view_updater_stage_diagnostics.js', array('jquery'));
        $enq('abj404-view-updater-compare',
            $vuBase . 'view_updater_compare.js', array('jquery'));
        $enq('abj404-view-updater-toast',
            $vuBase . 'view_updater_toast.js', array('jquery'));
        $enq('abj404-view-updater-stats', $vuBase . 'view_updater_stats.js',
            array('jquery', 'abj404-view-updater-toast', 'abj404-view-updater-nonce-refresh'));
        $enq('abj404-view-updater-build-advance', $vuBase . 'view_updater_build_advance.js',
            array('jquery', 'abj404-view-updater-stage-diagnostics', 'abj404-view-updater-nonce-refresh'));
        $enq('abj404-view-updater-table-init', $vuBase . 'view_updater_table_init.js',
            array('jquery', 'abj404-view-updater-toast', 'abj404-view-updater-stats',
                'abj404-view-updater-nonce-refresh'));
        $enq('abj404-view-updater-table-warmup', $vuBase . 'view_updater_table_warmup.js',
            array('jquery', 'abj404-view-updater-stage-diagnostics',
                'abj404-view-updater-build-advance', 'abj404-view-updater-table-init',
                'abj404-view-updater-nonce-refresh'));
        $enq('abj404-view-updater-pagination', $vuBase . 'view_updater_pagination.js',
            array('jquery', 'abj404-view-updater-compare', 'abj404-view-updater-stage-diagnostics',
                'abj404-view-updater-build-advance', 'abj404-view-updater-table-init',
                'abj404-view-updater-table-warmup', 'abj404-view-updater-toast',
                'abj404-view-updater-nonce-refresh'));
        $enq('abj404-view-updater', $vuBase . 'view_updater.js',
            array('jquery', 'jquery-ui-autocomplete',
                'abj404-view-updater-stage-diagnostics', 'abj404-view-updater-compare',
                'abj404-view-updater-toast', 'abj404-view-updater-stats',
                'abj404-view-updater-build-advance', 'abj404-view-updater-table-init',
                'abj404-view-updater-table-warmup', 'abj404-view-updater-pagination',
                'abj404-view-updater-nonce-refresh'));
    }

    private static function registerSupportRequestAssets(): void {
        ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-support-request-client',
            plugin_dir_url(__FILE__) . 'ajax/SupportRequest.js', array());
        ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-support-request-button',
            ABJ404_URL . 'includes/js/support-request-button.js',
            array('abj404-support-request-client'));
        if (!function_exists('wp_add_inline_script')) {
            return;
        }
        $supportNonce = wp_create_nonce(ABJ_404_Solution_Ajax_SupportRequest::NONCE_ACTION);
        $previewNonce = wp_create_nonce(ABJ_404_Solution_Ajax_SupportRequestPreview::NONCE_ACTION);
        $ajaxUrl = function_exists('admin_url') ? admin_url('admin-ajax.php') : '/wp-admin/admin-ajax.php';
        $payload = wp_json_encode(array(
            'ajaxurl' => $ajaxUrl,
            'nonces' => array(
                'support_request' => $supportNonce,
                'support_request_preview' => $previewNonce,
            ),
        ));
        $bootstrap = 'window.ABJ404=window.ABJ404||{};Object.assign(window.ABJ404,'
            . (is_string($payload) ? $payload : '{}') . ');';
        wp_add_inline_script('abj404-support-request-client', $bootstrap, 'before');
    }

    /** Detect if dark mode is enabled from various sources.
     * Checks WordPress admin color scheme, dark mode plugins, and browser preference.
     *
     * @return bool True if dark mode is detected, false otherwise
     */
    static function isDarkModeDetected() {
        // Check WordPress admin color scheme
        $current_user_id = get_current_user_id();
        if ($current_user_id) {
            $admin_color = get_user_meta($current_user_id, 'admin_color', true);
            // WordPress dark color schemes: midnight, ectoplasm, coffee
            $dark_schemes = array('midnight', 'ectoplasm', 'coffee');
            if (in_array($admin_color, $dark_schemes)) {
                return true;
            }
        }

        // Check for popular dark mode plugins
        // WP Dark Mode plugin
        if (get_option('wp_dark_mode_enabled')) {
            return true;
        }

        // Dark Mode for WP Dashboard plugin
        if (get_option('dark_mode_for_wp_dashboard_enabled')) {
            return true;
        }

        // Check if any dark mode plugin class exists
        if (class_exists('WP_Dark_Mode') || class_exists('Dark_Mode_For_WP_Dashboard')) {
            return true;
        }

        // Browser/OS preference will be checked via JavaScript
        return false;
    }

    /** Get the auto-selected theme based on dark mode detection.
     *
     * @return string The theme to use ('obsidian' for dark mode, 'default' otherwise)
     */
    static function getAutoSelectedTheme() {
        if (self::isDarkModeDetected()) {
            // Default to obsidian for dark mode (can be changed to 'neon' if preferred)
            return 'obsidian';
        }
        return 'default';
    }

    /** Output critical theme CSS inline to prevent FOUC (Flash of Unstyled Content).
     * This outputs the CSS variables for the selected theme directly in the <head>
     * before any external CSS files load, eliminating the flash when a custom theme is selected.
     *
     * Additionally, this sets the data-theme attribute on both HTML and body elements
     * via a synchronous script, ensuring the attribute exists before CSS is parsed.
     */
    /** @return void */
    static function outputCriticalThemeCSS() {
        try {
            // Only run on our plugin pages
            if (!array_key_exists('abj404_settingsPageName', $GLOBALS) ||
                !array_key_exists('page', $_GET) ||
                $_GET['page'] != ABJ404_PP) {
                return;
            }

            $logic = abj_service('plugin_logic');
            $options = $logic->getOptions();
            $theme = (isset($options['admin_theme']) && is_string($options['admin_theme'])) ? $options['admin_theme'] : 'default';

            // Check if auto dark mode detection is enabled (default: enabled)
            $auto_dark_mode = !isset($options['disable_auto_dark_mode']) || $options['disable_auto_dark_mode'] != '1';

            // If theme is 'default' and auto dark mode is enabled, check for dark mode
            if ($theme === 'default' && $auto_dark_mode) {
                $theme = self::getAutoSelectedTheme();
            }

            // Sanitize theme value - only allow specific values
            $allowed_themes = array('default', 'calm', 'mono', 'neon', 'obsidian');
            if (!in_array($theme, $allowed_themes)) {
                $theme = 'default';
            }

            // For 'default' theme, don't set data-theme attribute
            // This respects WordPress admin color scheme (default/Fresh is light)
            // and avoids overriding it with browser dark mode preference
            if ($theme === 'default') {
                // No theme CSS needed for default - use WordPress default styling
                // Ensure no data-theme attribute is set
                $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/themeRemoverScript.html");
                echo $html;
                return;
            }

            // Output synchronous script to set data-theme attributes immediately
            // This MUST run before CSS is parsed to prevent flash
            // Setting on html immediately, and body as soon as it's available
            $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/themeSetterScript.html");
            $f = abj_service('functions');
            $html = $f->str_replace('{theme}', esc_js($theme), $html);
            echo $html;

            // Define CSS variables for each theme
            $themeVariables = array(
            'mono' => array(
                '--abj404-bg' => '#F8FAFC',
                '--abj404-bg-muted' => '#F5F7FA',
                '--abj404-surface' => '#ffffff',
                '--abj404-surface-muted' => '#F1F5F9',
                '--abj404-text' => '#111827',
                '--abj404-text-muted' => '#6B7280',
                '--abj404-border' => '#E5E7EB',
                '--abj404-primary' => '#374151',
                '--abj404-accent' => '#2563EB',
                '--abj404-info' => '#3B82F6',
                '--abj404-success' => '#10B981',
                '--abj404-warning' => '#F59E0B',
                '--abj404-danger' => '#EF4444',
                '--abj404-focus' => '#93C5FD',
                '--abj404-table-header' => '#F1F5F9',
                '--abj404-row-hover' => '#F5F7FA',
                '--abj404-row-selected' => '#DBEAFE',
                '--abj404-badge-bg' => '#EFF1F5',
                '--abj404-badge-text' => '#374151',
            ),
            'calm' => array(
                '--abj404-bg' => '#F7FAFD',
                '--abj404-bg-muted' => '#F1F6FE',
                '--abj404-surface' => '#ffffff',
                '--abj404-surface-muted' => '#E9F0FB',
                '--abj404-text' => '#17223B',
                '--abj404-text-muted' => '#5A6B86',
                '--abj404-border' => '#E1E8F5',
                '--abj404-primary' => '#1E6BD6',
                '--abj404-accent' => '#00A27A',
                '--abj404-info' => '#2B8AE2',
                '--abj404-success' => '#20B67A',
                '--abj404-warning' => '#F6A700',
                '--abj404-danger' => '#D53F3F',
                '--abj404-focus' => '#5AA2FF',
                '--abj404-table-header' => '#E9F0FB',
                '--abj404-row-hover' => '#F1F6FE',
                '--abj404-row-selected' => '#D7E8FF',
                '--abj404-badge-bg' => '#EEF2F8',
                '--abj404-badge-text' => '#3E546E',
            ),
            'neon' => array(
                '--abj404-bg' => '#0C0F13',
                '--abj404-bg-muted' => '#11151A',
                '--abj404-surface' => '#151A21',
                '--abj404-surface-muted' => '#1B222B',
                '--abj404-text' => '#E5EAF2',
                '--abj404-text-muted' => '#A6B0C3',
                '--abj404-border' => '#273141',
                '--abj404-primary' => '#7C3AED',
                '--abj404-accent' => '#22D3EE',
                '--abj404-info' => '#60A5FA',
                '--abj404-success' => '#34D399',
                '--abj404-warning' => '#F59E0B',
                '--abj404-danger' => '#F87171',
                '--abj404-focus' => '#38BDF8',
                '--abj404-table-header' => '#1F2732',
                '--abj404-row-hover' => '#192028',
                '--abj404-row-selected' => '#0E2936',
                '--abj404-badge-bg' => '#202734',
                '--abj404-badge-text' => '#CFD8E6',
            ),
            'obsidian' => array(
                '--abj404-bg' => '#0A0F1A',
                '--abj404-bg-muted' => '#0E1522',
                '--abj404-surface' => '#121826',
                '--abj404-surface-muted' => '#172032',
                '--abj404-text' => '#E6ECF7',
                '--abj404-text-muted' => '#A9B7CC',
                '--abj404-border' => '#223149',
                '--abj404-primary' => '#1D4ED8',
                '--abj404-accent' => '#A78BFA',
                '--abj404-info' => '#60A5FA',
                '--abj404-success' => '#22C55E',
                '--abj404-warning' => '#F59E0B',
                '--abj404-danger' => '#EF4444',
                '--abj404-focus' => '#93C5FD',
                '--abj404-table-header' => '#1B253A',
                '--abj404-row-hover' => '#141C2C',
                '--abj404-row-selected' => '#1A2A46',
                '--abj404-badge-bg' => '#1A2438',
                '--abj404-badge-text' => '#DCE6F7',
            ),
        );

            // Output inline critical CSS if theme is selected
            /** @var string $themeKey */
            $themeKey = $theme;
            if (isset($themeVariables[$themeKey])) {
                // Build CSS variables string
                $cssVars = '';
                foreach ($themeVariables[$themeKey] as $var => $value) {
                    $cssVars .= esc_html($var) . ':' . esc_html($value) . ';';
                }

                // Load template and replace placeholder
                $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/criticalThemeCSS.html");
                $f = abj_service('functions');
                $html = $f->str_replace('{css_variables}', $cssVars, $html);
                echo $html;
            }
        } catch (Throwable $e) {
            self::reportAdminRuntimeError('admin_head', $e);
        }
    }

    /**
     * @param string $content
     * @return string
     */
    static function remove_admin_footer_text($content) {
        return '';
    }

    /** Add the "Settings" link to the WordPress plugins page (next to activate/deactivate and edit).
     * @param array<int|string, string> $links
     * @return array<int|string, string>
     */
    static function addSettingsLinkToPluginPage($links) {
        $instance = self::getInstance();

        if (!is_array($links)) {
        	$instance->logger->infoMessage("The settings links variable was not an array. " .
        		"Please verify the validity of other plugins. " . print_r($links, true));
            $links = array();
        }

        if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
            $instance->logger->logUserCapabilities("addSettingsLinkToPluginPage");

            return $links;
        }

        $settings_link = '<a href="options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options">' .
                __('Settings', '404-solution') . '</a>';
        array_unshift($links, $settings_link);

        $debugExplanation = __('Debug Log', '404-solution');
        $debugLogLink = $instance->logic->getDebugLogFileLink();
        $debugExplanation = '<a href="options-general.php' . $debugLogLink . '" target="_blank" >'
        	. $debugExplanation . '</a>';
        array_push($links, $debugExplanation);

        return $links;
    }

    /**
     * Adds a "Send debug log to developer" link to the plugin row on
     * the Plugins page. The link opens the support-request consent
     * modal in-place on wp-admin/plugins.php (handled by
     * support-request-button.js, which attaches to elements matching
     * .abj404-support-request-link). Opening in-place is deliberate:
     * the Plugins listing is the screen an admin reaches when the
     * plugin's own Settings page is broken, so the modal must not
     * depend on Settings rendering correctly.
     *
     * The href falls back to the same-page anchor `#abj404-support-request`
     * so the link is still well-formed if support-request-button.js
     * fails to load. The modal itself is the only path that transmits
     * the support-request payload; clicking the link never POSTs.
     *
     * @param array<int|string, string> $links
     * @param string $file
     * @return array<int|string, string>
     */
    static function addPluginRowMeta($links, $file) {
        if ($file !== ABJ404_NAME) {
            return $links;
        }
        $links[] = '<a href="#abj404-support-request"'
            . ' class="abj404-support-request-link"'
            . ' data-triggered-from="plugins_row_action">'
            . esc_html__('Send debug log to developer', '404-solution') . '</a>';
        return $links;
    }

    /** This is called directly by php code inserted into the page by the user.
     * Code: <?php if (!empty($abj404connector)) {$abj404connector->suggestions(); } ?>
     * @global type $abj404shortCode
     */
    /** @return void */
    function suggestions() {
        $abj404shortCode = abj_service('shortcode');

        if (is_404()) {
            $content = $abj404shortCode->shortcodePageSuggestions(array());

            echo $content;
        }
    }

    /** @return void */
    function processRedirectAllRequests() {
        $this->getFrontendPipeline()->processRedirectAllRequests();
    }
    /**
     * Process the 404s
     */
    /** @return void */
    function process404() {
        $this->getFrontendPipeline()->process404();
    }

    /**
     * @param array<string, mixed> $options
     * @param string $requestedURL
     * @return bool true if the user is sent to the default 404 page.
     */
    function tryRegexRedirect($options, $requestedURL) {
        return $this->getFrontendPipeline()->tryRegexRedirect($options, $requestedURL);
    }
    
	/**
	 * @param array<string, mixed> $options
	 * @param string $requestedURL
	 * @param array<string, mixed> $redirect
	 * @return void
	 */
    function logAReallyLongDebugMessage($options, $requestedURL, $redirect) {
        $this->getFrontendPipeline()->logAReallyLongDebugMessage($options, $requestedURL, $redirect);
	}
    
    /** Redirect to the page specified.
     * @param string $requestedURL
     * @param array<string, mixed> $redirect
     * @param string $matchReason
     * @return bool true if the user is sent to the default 404 page.
     */
    function processRedirect($requestedURL, $redirect, $matchReason) {
        return $this->getFrontendPipeline()->processRedirect($requestedURL, $redirect, $matchReason);
    }

    /** Display an admin dashboard notification.
     * e.g. There are 29 captured 404 URLs to be processed.
     * @global type $pagenow
     * @global type $abj404dao
     * @global type $abj404logic
     * @global type $abj404view
     */
    /** @return void */
    static function echoDashboardNotification() {
        $instance = self::getInstance();

        if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
            $instance->logger->logUserCapabilities("echoDashboardNotification");
            return;
        }

        self::echoAdminRuntimeErrorNotice();

        global $pagenow;
        global $abj404view;

        $isPluginPage = array_key_exists('page', $_GET) && $_GET['page'] == ABJ404_PP;
        $isDashboard  = $pagenow == 'index.php' && !isset($_GET['page']);

        // Display infrastructure notices (DB errors, stale cache, etc.) only on
        // the plugin's own admin pages — not on the dashboard or other screens.
        // This hook runs early in the request; rendering here ensures a notice
        // set by a failed repair attempt is visible before later queries might
        // auto-clear stale transients.
        if ($isPluginPage) {
            $dbNotice = get_transient('abj404_plugin_db_notice');
            if (is_array($dbNotice) && isset($dbNotice['message']) && is_string($dbNotice['message'])) {
                $type = isset($dbNotice['type']) && is_string($dbNotice['type']) ? $dbNotice['type'] : 'warning';
                // Per owner directive: collation issues must NEVER surface as user notices.
                // The plugin auto-recovers by running correctCollations() at query time.
                // Skip rendering even if a stale 'collation' transient exists from a
                // previous plugin version.
                if ($type === 'collation') {
                    // intentionally do not render
                } else {
                    // Map internal type names to WP notice CSS classes. Per the
                    // defensive-coding philosophy, infrastructure issues the plugin
                    // can degrade past (read-only DB, disk full, quota exceeded,
                    // stale cache, generic warning) render as notice-warning. Only
                    // failures that leave the plugin unable to function (e.g.
                    // missing tables that can't be repaired) escalate to
                    // notice-error.
                    $warningTypes = array(
                        'stale_permalink_cache',
                        'warning',
                        'read_only',
                        'disk_full',
                        'query_quota',
                    );
                    $cssClass = in_array($type, $warningTypes, true) ? 'notice-warning' : 'notice-error';
                    echo '<div class="notice ' . esc_attr($cssClass) . '"><p>' .
                        esc_html($dbNotice['message']) . '</p></div>';
                }
            }
        }

        if ($isPluginPage || $isDashboard) {
            $captured404Count = $instance->dao->getCapturedCountForNotification();
            if ($instance->logic->shouldNotifyAboutCaptured404s($captured404Count)) {
                $msg = $abj404view->getDashboardNotificationCaptured($captured404Count);
                echo $msg;
            }

            // Show review request after 7 days of use
            self::maybeShowReviewRequest();
        }
    }

    /** Handle review GET redirects and feedback POST submission on admin_init (before output).
     *
     * Called via the admin_init hook so wp_safe_redirect() + exit can be used safely.
     * For feedback POST (no redirect), sets the static $feedbackSubmitted flag so
     * maybeShowReviewRequest() can render the thank-you notice in the admin_notices hook.
     *
     * @return void
     */
    static function handleReviewResponseRedirects() {
        if (!is_admin()) {
            return;
        }
        if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
            return;
        }

        // Handle user responses to qualification question
        if (isset($_GET['abj404_review_response'])) {
            $rawResponseNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
            $responseNonce = sanitize_text_field(self::normalizeRequestScalar($rawResponseNonce));
            if ($responseNonce === '' || !wp_verify_nonce($responseNonce, 'abj404_review_response')) {
                return;
            }

            $response = sanitize_text_field(self::normalizeRequestScalar($_GET['abj404_review_response']));
            $allowedResponses = array('yes', 'not_yet', 'ask_later', 'close_x', 'never');
            if (!in_array($response, $allowedResponses, true)) {
                return;
            }

            if ($response === 'yes') {
                // User thinks it deserves 5 stars - show review link
                update_user_meta(get_current_user_id(), 'abj404_review_step', 'show_review_link');
                delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
            } elseif ($response === 'not_yet') {
                // User doesn't think it deserves 5 stars - show feedback form
                update_user_meta(get_current_user_id(), 'abj404_review_step', 'show_feedback');
                delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
            } elseif ($response === 'ask_later') {
                // User wants to be reminded in 7 days
                update_user_meta(get_current_user_id(), 'abj404_review_remind_later', time() + (self::REVIEW_ASK_LATER_DELAY_DAYS * 86400));
                delete_user_meta(get_current_user_id(), 'abj404_review_step');
            } elseif ($response === 'close_x') {
                // Close button snoozes this prompt for at least two weeks.
                update_user_meta(get_current_user_id(), 'abj404_review_remind_later', time() + (self::REVIEW_CLOSE_X_SNOOZE_DAYS * 86400));
                delete_user_meta(get_current_user_id(), 'abj404_review_step');
            } elseif ($response === 'never') {
                // User never wants to see this - PERMANENT dismissal
                update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
                delete_user_meta(get_current_user_id(), 'abj404_review_step');
                delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
            }

            // Redirect to remove query parameter and show the appropriate notice
            wp_safe_redirect(remove_query_arg(array('abj404_review_response', '_wpnonce')));
            exit;
        }

        // Handle "Going to review now" button click - PERMANENT dismissal
        if (isset($_GET['abj404_leaving_review'])) {
            $rawLeavingReviewNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
            $leavingReviewNonce = sanitize_text_field(self::normalizeRequestScalar($rawLeavingReviewNonce));
            if ($leavingReviewNonce !== '' && wp_verify_nonce($leavingReviewNonce, 'abj404_leaving_review')) {
                update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
                delete_user_meta(get_current_user_id(), 'abj404_review_step');
                delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');

                // Open review page in new tab and redirect current page to clean URL
                $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewRedirectScript.html");
                $f = abj_service('functions');
                $html = $f->str_replace('{review_url}', esc_js('https://wordpress.org/support/plugin/404-solution/reviews/#new-post'), $html);
                echo $html;
                wp_safe_redirect(remove_query_arg(array('abj404_leaving_review', '_wpnonce')));
                exit;
            }
        }

        // Handle feedback POST submission - PERMANENT dismissal
        $rawFeedbackNonce = isset($_POST['abj404_feedback_nonce']) ? $_POST['abj404_feedback_nonce'] : '';
        $feedbackNonce = sanitize_text_field(self::normalizeRequestScalar($rawFeedbackNonce));
        if (isset($_POST['abj404_submit_feedback']) &&
            $feedbackNonce !== '' &&
            wp_verify_nonce($feedbackNonce, 'abj404_submit_feedback')) {

            // Get selected issues (checkboxes) and normalize malformed inputs safely.
            $issuesRaw = isset($_POST['feedback_issues']) ? $_POST['feedback_issues'] : array();
            $issues = self::sanitizeFeedbackIssues($issuesRaw);

            $feedbackDetailsRaw = isset($_POST['feedback_details']) ? $_POST['feedback_details'] : '';
            $feedback_details = sanitize_textarea_field(self::normalizeRequestScalar($feedbackDetailsRaw));

            // Prepare feedback data
            $feedback_data = array(
                'timestamp' => current_time('mysql'),
                'user_id' => get_current_user_id(),
                'site_url' => get_site_url(),
                'issues' => $issues,
                'details' => $feedback_details,
                'wp_version' => get_bloginfo('version'),
                'plugin_version' => ABJ404_VERSION,
                'php_version' => PHP_VERSION
            );

            // Store feedback in database
            $all_feedback_raw = get_option('abj404_user_feedback', array());
            $all_feedback = is_array($all_feedback_raw) ? $all_feedback_raw : array();
            $all_feedback[] = $feedback_data;
            update_option('abj404_user_feedback', $all_feedback);

            // Email feedback to plugin author
            self::emailFeedback($feedback_data);

            // PERMANENT dismissal - never show again
            update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
            delete_user_meta(get_current_user_id(), 'abj404_review_step');
            delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');

            // Signal to maybeShowReviewRequest() to render the thank-you notice
            self::$feedbackSubmitted = true;
        }
    }

    /** Display a review request notification after a sustained period of plugin use.
     * Uses a qualification question to ensure only satisfied users are directed to leave reviews.
     * Unhappy users are directed to provide feedback instead.
     *
     * Guarantees:
     * - Never shows again after user clicks "Never ask again"
     * - Never shows again after user clicks review link button
     * - Never shows again after user submits feedback
     * - Shows again in 7 days after "Ask again later"
     * - Shows again in 14 days after close "X"
     */
    /** @return void */
    static function maybeShowReviewRequest() {
        // Only show on 404 Solution plugin pages
        if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
            return;
        }

        // If feedback was submitted this request (processed early by handleReviewResponseRedirects),
        // show the thank-you notice immediately — before any other checks, since those checks
        // would bail because handleReviewResponseRedirects already set dismissed=permanent.
        if (self::$feedbackSubmitted) {
            $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/feedbackSuccessNotice.html");
            echo $html;
            return;
        }

        // Check if user permanently dismissed this
        $dismissed = get_user_meta(get_current_user_id(), 'abj404_review_dismissed', true);
        if ($dismissed === 'permanent') {
            return;
        }

        // Check if user asked to be reminded later
        $remind_later = get_user_meta(get_current_user_id(), 'abj404_review_remind_later', true);
        if ($remind_later && time() < $remind_later) {
            // Not time yet to remind
            return;
        }

        // Get plugin installation/activation time
        $installed_time = get_option('abj404_installed_time');
        if (!$installed_time) {
            // First time - record installation time
            $installed_time = time();
            update_option('abj404_installed_time', $installed_time);
            return;
        }

        // Show review request after enough real usage time has passed.
        $days_installed = (time() - $installed_time) / 86400;
        if ($days_installed < self::REVIEW_INITIAL_DELAY_DAYS) {
            return;
        }

        // Check what step we're on
        $review_step = get_user_meta(get_current_user_id(), 'abj404_review_step', true);

        if ($review_step === 'show_review_link') {
            // Step 2a: User said YES - show review link
            self::showReviewLinkNotice();
        } elseif ($review_step === 'show_feedback') {
            // Step 2b: User said NOT YET - show feedback form
            self::showFeedbackFormNotice();
        } else {
            // Step 1: Initial qualification question
            self::showQualificationQuestion();
        }
    }

    /** Email feedback to plugin author.
     * @param array<string, mixed> $feedback_data
     * @return void
     */
    private static function emailFeedback($feedback_data) {
        $to = '404solution@ajexperience.com';
        $subject = '404 Solution Feedback from ' . get_bloginfo('name');

        $message = "New feedback received from 404 Solution plugin\n\n";
        $message .= "Site: " . $feedback_data['site_url'] . "\n";
        $message .= "Date: " . $feedback_data['timestamp'] . "\n";
        $message .= "WordPress Version: " . $feedback_data['wp_version'] . "\n";
        $message .= "Plugin Version: " . $feedback_data['plugin_version'] . "\n";
        $message .= "PHP Version: " . $feedback_data['php_version'] . "\n\n";

        $message .= "Issues Selected:\n";
        $feedbackIssues = isset($feedback_data['issues']) && is_array($feedback_data['issues']) ? $feedback_data['issues'] : array();
        if (!empty($feedbackIssues)) {
            foreach ($feedbackIssues as $issue) {
                $issueStr = is_string($issue) ? $issue : (string)$issue;
                $message .= "  - " . ucfirst(str_replace('_', ' ', $issueStr)) . "\n";
            }
        } else {
            $message .= "  None selected\n";
        }

        $message .= "\nAdditional Details:\n";
        $message .= $feedback_data['details'] ? $feedback_data['details'] : "(No additional details provided)\n";

        $headers = array('Content-Type: text/plain; charset=UTF-8');

        wp_mail($to, $subject, $message, $headers);
    }

    /** Step 1: Show the initial qualification question.
     * @return void
     */
    private static function showQualificationQuestion() {
        $yes_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'yes'),
            'abj404_review_response'
        );
        $not_yet_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'not_yet'),
            'abj404_review_response'
        );
        $ask_later_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'ask_later'),
            'abj404_review_response'
        );
        $never_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'never'),
            'abj404_review_response'
        );
        $close_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'close_x'),
            'abj404_review_response'
        );

        $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewQualificationQuestion.html");
        $f = abj_service('functions');
        $html = $f->str_replace('{yes_url}', esc_attr($yes_url), $html);
        $html = $f->str_replace('{not_yet_url}', esc_attr($not_yet_url), $html);
        $html = $f->str_replace('{ask_later_url}', esc_attr($ask_later_url), $html);
        $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
        $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
        echo $html;
    }

    /** Step 2a: User said YES - show review link and thank you.
     * @return void
     */
    private static function showReviewLinkNotice() {
        // URL that marks as done when they click to go leave review
        $review_link_url = wp_nonce_url(
            add_query_arg('abj404_leaving_review', '1'),
            'abj404_leaving_review'
        );

        $never_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'never'),
            'abj404_review_response'
        );
        $close_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'close_x'),
            'abj404_review_response'
        );

        $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewLinkNotice.html");
        $f = abj_service('functions');
        $html = $f->str_replace('{review_link_url}', esc_attr($review_link_url), $html);
        $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
        $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
        echo $html;
    }

    /** Step 2b: User said NOT YET - show feedback form.
     * @return void
     */
    private static function showFeedbackFormNotice() {
        $never_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'never'),
            'abj404_review_response'
        );
        $close_url = wp_nonce_url(
            add_query_arg('abj404_review_response', 'close_x'),
            'abj404_review_response'
        );

        // Get nonce field HTML
        ob_start();
        wp_nonce_field('abj404_submit_feedback', 'abj404_feedback_nonce');
        $nonce_field = ob_get_clean();
        if ($nonce_field === false) { $nonce_field = ''; }

        $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/feedbackFormNotice.html");
        $f = abj_service('functions');
        $html = $f->str_replace('{nonce_field}', $nonce_field, $html);
        $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
        $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
        echo $html;
    }

    /**
     * Safely unslash request data when wp_unslash exists and is callable.
     * Some test environments report wp_unslash as existing but throw when called.
     *
     * @param mixed $value
     * @return mixed
     */
    private static function safeWpUnslash($value) {
        if (!function_exists('wp_unslash')) {
            return $value;
        }

        try {
            return wp_unslash($value);
        } catch (Throwable $e) {
            return $value;
        }
    }

    /**
     * Normalize request input to a scalar string to avoid warnings when arrays/objects are passed.
     *
     * @param mixed $value
     * @return string
     */
    private static function normalizeRequestScalar($value) {
        $value = self::safeWpUnslash($value);
        if (!is_scalar($value)) {
            return '';
        }
        return (string)$value;
    }

    /**
     * Normalize and sanitize feedback issue selections from request data.
     *
     * @param mixed $issuesRaw
     * @return array<int, string>
     */
    private static function sanitizeFeedbackIssues($issuesRaw) {
        $issuesRaw = self::safeWpUnslash($issuesRaw);
        if (!is_array($issuesRaw)) {
            $issuesRaw = array($issuesRaw);
        }

        $issues = array();
        foreach ($issuesRaw as $issue) {
            if (is_array($issue) || is_object($issue)) {
                continue;
            }
            $clean = sanitize_text_field((string)$issue);
            if ($clean !== '') {
                $issues[] = $clean;
            }
        }
        return $issues;
    }

    /** Adds a link under the "Settings" link to the plugin page.
     * @global string $menu
     * @global type $abj404dao
     * @global type $abj404logic
     * @global type $abj404logging
     */
    /** @return void */
    static function addMainSettingsPageLink() {
        global $menu;

        // The menu must ALWAYS be registered so the admin page is accessible.
        // Wrap all pre-registration logic in try/catch — if anything fails
        // (missing tables, broken service container, etc.), fall through to
        // register the menu with safe defaults.
        $pageName = "404 Solution";
        $menuLocation = '';

        try {
            $instance = self::getInstance();

            if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
                $instance->logger->logUserCapabilities("addMainSettingsPageLink");
                return;
            }

            // Use skip_db_check=true so menu registration never triggers
            // updateToNewVersion() — that can hang on slow database upgrades
            // and block the entire admin page from rendering.
            $options = $instance->logic->getOptions(true);
            $menuLocation = isset($options['menuLocation']) ? $options['menuLocation'] : '';

            // Admin notice badge
            if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
                $captured = $instance->dao->getCapturedCountForNotification();
                if ($captured >= $options['admin_notification']) {
                    $pageName .= " <span class='update-plugins count-1'><span class='update-count'>" . esc_html((string)$captured) . "</span></span>";
                    if (isset($menu[80][0])) {
                        $pos = $instance->f->strpos($menu[80][0], 'update-plugins');
                        if ($pos === false) {
                            $menu[80][0] = $menu[80][0] . " <span class='update-plugins count-1'><span class='update-count'>1</span></span>";
                        }
                    }
                }
            }
        } catch (\Throwable $e) {
            // Something failed before menu registration. Continue with defaults
            // so the admin page is still accessible for debugging. Surface the
            // failure to PHP's error log so it isn't completely invisible.
            error_log('404 Solution: addMainSettingsPageLink pre-registration failed: ' . $e->getMessage());
        }

        if ($menuLocation === 'settingsLevel') {
            // this adds the settings link at the same level as the "Tools" and "Settings" menu items.
			$GLOBALS['abj404_settingsPageName'] = add_menu_page(PLUGIN_NAME, PLUGIN_NAME, 'manage_options', 'abj404_solution',
                    'abj404_admin_page_callback');

        } else {
            // this adds the settings link at Settings->404 Solution.
        	$GLOBALS['abj404_settingsPageName'] = add_submenu_page('options-general.php', PLUGIN_NAME, $pageName, 'manage_options', ABJ404_PP,
                    'abj404_admin_page_callback');
        }
    }

    /**
     * AJAX handler: OAuth callback from Google (custom mode) or from the
     * centralized Worker (centralized mode).
     *
     * In centralized mode the Worker has already exchanged the authorization
     * code for tokens, so the callback URL contains access_token, refresh_token,
     * and expires_in as query parameters. The `abj404_gsc_centralized` flag
     * distinguishes the two flows.
     *
     * @return void
     */
    public static function handleGscOauthCallback() {
        if (!current_user_can('manage_options')) {
            wp_die(__('Insufficient permissions.', '404-solution'), 403);
        }

        $logger = abj_service('logging');
        $gsc    = new ABJ_404_Solution_GoogleSearchConsole($logger);

        $isCentralized = isset($_GET['abj404_gsc_centralized']) && $_GET['abj404_gsc_centralized'] === '1';

        if ($isCentralized) {
            self::handleCentralizedGscCallback($gsc);
            return;
        }

        // --- Custom-credentials flow (original behavior) ---
        $code  = isset($_GET['code'])  ? sanitize_text_field((string)$_GET['code'])  : '';
        $state = isset($_GET['state']) ? sanitize_text_field((string)$_GET['state']) : '';

        // Verify state nonce to prevent CSRF.
        if (!wp_verify_nonce($state, 'abj404_gsc_oauth')) {
            wp_die(__('Security check failed.', '404-solution'), 403);
        }

        if ($code === '') {
            // User denied access or error occurred.
            $gsc->setLastOAuthError(__('Authorization was denied or cancelled.', '404-solution'));
            wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
            exit;
        }

        $error = $gsc->exchangeCodeForToken($code);

        if ($error !== '') {
            $gsc->setLastOAuthError($error);
        }
        wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
        exit;
    }

    /**
     * Handle the centralized OAuth callback. Tokens arrive as URL parameters
     * from the Worker, so no code exchange is needed.
     *
     * @param ABJ_404_Solution_GoogleSearchConsole $gsc
     * @return void
     */
    private static function handleCentralizedGscCallback(ABJ_404_Solution_GoogleSearchConsole $gsc): void {
        $nonce = isset($_GET['nonce']) ? sanitize_text_field((string)$_GET['nonce']) : '';

        // Verify round-tripped nonce for CSRF protection.
        if (!wp_verify_nonce($nonce, 'abj404_gsc_oauth')) {
            wp_die(__('Security check failed.', '404-solution'), 403);
        }

        // Check for error from the Worker.
        $error = isset($_GET['abj404_gsc_error']) ? sanitize_text_field((string)$_GET['abj404_gsc_error']) : '';
        if ($error !== '') {
            $gsc->setLastOAuthError($error);
            wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
            exit;
        }

        $accessToken  = isset($_GET['access_token'])  ? sanitize_text_field((string)$_GET['access_token'])  : '';
        $refreshToken = isset($_GET['refresh_token']) ? sanitize_text_field((string)$_GET['refresh_token']) : '';
        $expiresIn    = isset($_GET['expires_in'])    ? (int)$_GET['expires_in']                             : 3600;

        if ($accessToken === '') {
            $gsc->setLastOAuthError(__('No access token received from authorization.', '404-solution'));
            wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
            exit;
        }

        $gsc->storeCentralizedTokens($accessToken, $refreshToken, $expiresIn);

        wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
        exit;
    }

    /**
     * AJAX handler: revoke GSC authorization.
     * @return void
     */
    public static function handleGscRevoke() {
        if (!current_user_can('manage_options') || !check_admin_referer('abj404_gsc_revoke')) {
            wp_die(__('Security check failed.', '404-solution'), 403);
        }

        $logger = abj_service('logging');
        $gsc    = new ABJ_404_Solution_GoogleSearchConsole($logger);
        $gsc->revokeAuthorization();

        wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
        exit;
    }

}

```
