<?php
/**
 * TamRank Onboarding Wizard Controller
 *
 * Handles admin page registration, activation redirect,
 * asset enqueuing, and AJAX endpoint routing.
 */

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

class TamRank_Onboarding {

    /** @var string */
    const PAGE_SLUG = 'tamrank-onboarding';

    /** @var string */
    const OPTION_COMPLETE = 'tamrank_onboarding_complete';

    /** @var string */
    const TRANSIENT_REDIRECT = 'tamrank_activation_redirect';

    /** @var string Option holding email opt-in state. Shape: array{accepted_at:int, email:string, language:string, synced_at:?int}. */
    const OPTION_EMAIL_OPTIN = 'tamrank_email_tips_optin';

    /** @var string Throttle transient — prevents re-trying sync more than once per hour. */
    const TRANSIENT_OPTIN_SYNC_LOCK = 'tamrank_email_optin_sync_lock';

    /** @var string Option: the "another SEO plugin is active, migrate first" notice was dismissed. */
    const OPTION_COEX_MIGRATE_FIRST_DISMISSED = 'tamrank_coex_migrate_first_dismissed';

    /** @var string Option: the "migration is done, the other plugin still runs" notice was dismissed. */
    const OPTION_COEX_STILL_ACTIVE_DISMISSED = 'tamrank_coex_still_active_dismissed';

    public function __construct() {
        add_action( 'admin_menu',            array( $this, 'register_page' ) );
        add_action( 'admin_init',            array( $this, 'maybe_redirect' ) );
        add_action( 'admin_init',            array( $this, 'maybe_sync_email_optin' ) );
        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );

        // AJAX endpoints
        add_action( 'wp_ajax_tamrank_onboarding_set_language', array( $this, 'ajax_set_language' ) );
        add_action( 'wp_ajax_tamrank_onboarding_skip',      array( $this, 'ajax_skip' ) );
        add_action( 'wp_ajax_tamrank_onboarding_detect',     array( $this, 'ajax_detect' ) );
        add_action( 'wp_ajax_tamrank_onboarding_scan',       array( $this, 'ajax_scan' ) );
        add_action( 'wp_ajax_tamrank_onboarding_settings_preview', array( $this, 'ajax_settings_preview' ) );
        add_action( 'wp_ajax_tamrank_onboarding_migrate',    array( $this, 'ajax_migrate' ) );
        add_action( 'wp_ajax_tamrank_onboarding_rollback',   array( $this, 'ajax_rollback' ) );
        add_action( 'wp_ajax_tamrank_onboarding_complete',   array( $this, 'ajax_complete' ) );
        add_action( 'wp_ajax_tamrank_dismiss_migration_banner', array( $this, 'ajax_dismiss_migration_banner' ) );
        add_action( 'wp_ajax_tamrank_dismiss_coex_migrate_first', array( $this, 'ajax_dismiss_coex_migrate_first' ) );
        add_action( 'wp_ajax_tamrank_dismiss_coex_still_active', array( $this, 'ajax_dismiss_coex_still_active' ) );

        // Dashboard banner
        add_action( 'admin_notices', array( $this, 'maybe_show_banner' ) );
    }

    /* ------------------------------------------------------------------
     * Admin page (hidden from menu)
     * ----------------------------------------------------------------*/

    public function register_page() {
        $page_title = tamrank__( 'onboarding_wizard_title', 'TamRank Setup Wizard' );
        $hook = add_submenu_page(
            null, // hidden
            $page_title,
            tamrank__( 'onboarding_wizard_menu', 'Setup Wizard' ),
            'manage_options',
            self::PAGE_SLUG,
            array( $this, 'render_page' )
        );

        // Hidden pages (parent = null) are not in $submenu, so get_admin_page_title()
        // leaves $title empty and WP core runs strip_tags(null) (PHP 8.1+ deprecation).
        // Set the global title early on this page's load, before admin-header.php renders.
        if ( $hook ) {
            add_action( "load-$hook", function () use ( $page_title ) {
                $GLOBALS['title'] = $page_title;
            } );
        }
    }

    public function render_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html(tamrank__('error_permission_denied', 'You do not have permission for this action.')) );
        }

        // Load scanner for initial detection data
        if ( ! class_exists( 'TamRank_SEO_Scanner' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-seo-scanner.php';
        }

        $scanner   = new TamRank_SEO_Scanner();
        $detection = $scanner->detect_seo_plugin();

        include TAM_RANK_PLUGIN_PATH . 'templates/onboarding/wizard.php';
        exit; // Full-screen: stop WP from rendering admin chrome after template
    }

    /* ------------------------------------------------------------------
     * Activation redirect (one-time)
     * ----------------------------------------------------------------*/

    public function maybe_redirect() {
        if ( ! get_transient( self::TRANSIENT_REDIRECT ) ) {
            return;
        }

        delete_transient( self::TRANSIENT_REDIRECT );

        // Don't redirect on multisite bulk activate, WP-CLI, or AJAX
        if ( is_network_admin() || isset( $_GET['activate-multi'] ) || wp_doing_ajax() || defined( 'WP_CLI' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request inspection (admin page / OAuth callback / menu detection); no state-changing action to protect.
            return;
        }

        // Don't redirect if already completed
        if ( get_option( self::OPTION_COMPLETE ) ) {
            return;
        }

        wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
        exit;
    }

    /* ------------------------------------------------------------------
     * Assets
     * ----------------------------------------------------------------*/

    public function enqueue_assets( $hook ) {
        if ( 'admin_page_' . self::PAGE_SLUG !== $hook ) {
            return;
        }

        wp_enqueue_style(
            'tamrank-onboarding',
            TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-onboarding.css',
            array( 'tamrank-variables' ), // tokens: geen admin-global op deze hook
            TAM_RANK_VERSION
        );

        wp_enqueue_script(
            'tamrank-onboarding',
            TAM_RANK_PLUGIN_URL . 'assets/js/tamrank-onboarding.js',
            array(),
            TAM_RANK_VERSION,
            true
        );

        wp_localize_script( 'tamrank-onboarding', 'tamrankOnboarding', array(
            'ajaxUrl'         => admin_url( 'admin-ajax.php' ),
            'nonce'           => wp_create_nonce( 'tamrank_onboarding' ),
            'dashUrl'         => admin_url( 'admin.php?page=tam-rank-dashboard' ),
            'wizardUrl'       => admin_url( 'admin.php?page=' . self::PAGE_SLUG ),
            'currentLanguage' => tamrank_get_language(),
            'strings'  => array(
                'error'                      => tamrank__( 'wizard_error', 'An error occurred' ),
                'retry'                      => tamrank__( 'wizard_retry', 'Retry' ),
                'skip'                       => tamrank__( 'onboarding_skip', 'Skip' ),
                // Wave 3 additions
                'found'                      => tamrank__( 'found', 'found' ),
                'detect_no_data'             => tamrank__( 'detect_no_data', 'We found %s but no SEO data yet. Add meta titles and descriptions in the editor.' ),
                'detect_has_data'            => tamrank__( 'detect_has_data', 'We can safely import your existing SEO data to TamRank. In the next step we will scan your site for a complete overview.' ),
                'detect_no_plugin'           => tamrank__( 'detect_no_plugin', 'No SEO plugin found' ),
                'detect_fresh_start'         => tamrank__( 'detect_fresh_start', 'Start with a clean slate' ),
                'detect_no_plugin_desc'      => tamrank__( 'detect_no_plugin_desc', 'No problem! TamRank is your complete SEO solution. In the next step we will scan your site to see where you can improve.' ),
                'large_site_warning'         => tamrank__( 'large_site_warning', '%1$s items means the migration takes %2$s. You can leave this tab open.' ),
                'migration_complete_with_notes' => tamrank__( 'migration_complete_with_notes', 'Migration complete with remarks' ),
                'items_migrated'             => tamrank__( 'items_migrated', 'items migrated' ),
                'items_skipped'              => tamrank__( 'items_skipped', 'items skipped' ),
                'item_skipped'               => tamrank__( 'item_skipped', 'item skipped' ),
                'items_with_seo_data'        => tamrank__( 'items_with_seo_data', 'items with SEO data' ),
                'item_with_seo_data'         => tamrank__( 'item_with_seo_data', 'item with SEO data' ),
                'detected'                   => tamrank__( 'detected', 'Detected' ),
                'detect_not_installed'       => tamrank__( 'detect_not_installed', 'Not installed' ),
                'wizard_clean_start'         => tamrank__( 'wizard_clean_start', 'Clean start' ),
                'wizard_clean_start_desc'    => tamrank__( 'wizard_clean_start_desc', 'Start fresh without migration' ),
                'wizard_plugin_list_aria'    => tamrank__( 'wizard_plugin_list_aria', 'Choose how to start' ),
                'multiSourceDesc'            => tamrank__( 'wizard_multi_source_desc', 'We found SEO data from %d plugins. Choose which one you want to import. The rest stays untouched.' ),
                // TRUST-01 - the three anchor points. Every "%s" variant has a
                // generic twin for the case where no plugin name is known; the
                // old wizard_safety_message pair is gone with its "Rollback is
                // always possible", which claimed more than the mechanism does.
                'wizard_detect_trust'        => tamrank__( 'wizard_detect_trust', 'TamRank reads your %s data. It never changes or deletes it.' ),
                'wizard_detect_trust_generic' => tamrank__( 'wizard_detect_trust_generic', 'TamRank reads your existing SEO data. It never changes or deletes it.' ),
                'wizard_detect_trust_keep'   => tamrank__( 'wizard_detect_trust_keep', 'You do not have to remove %s. Keep it active until you are happy with the result.' ),
                'wizard_detect_trust_keep_generic' => tamrank__( 'wizard_detect_trust_keep_generic', 'You do not have to remove your old SEO plugin. Keep it active until you are happy with the result.' ),
                'wizard_trust_title'         => tamrank__( 'wizard_trust_title', 'You can switch back to %s at any time.' ),
                'wizard_trust_title_generic' => tamrank__( 'wizard_trust_title_generic', 'You can switch back at any time.' ),
                'wizard_trust_copy'          => tamrank__( 'wizard_trust_copy', 'We copy your %s data. We never move or delete anything.' ),
                'wizard_trust_copy_generic'  => tamrank__( 'wizard_trust_copy_generic', 'We copy your existing SEO data. We never move or delete anything.' ),
                'wizard_trust_backup'        => tamrank__( 'wizard_trust_backup', 'Before anything changes we save a backup. The backup is kept until you use it.' ),
                'wizard_trust_keep'          => tamrank__( 'wizard_trust_keep', 'Keep %s active until you are happy with the result. Turning it off can wait.' ),
                'wizard_trust_keep_generic'  => tamrank__( 'wizard_trust_keep_generic', 'Keep your old SEO plugin active until you are happy with the result. Turning it off can wait.' ),
                'wizard_rollback_body'       => tamrank__( 'wizard_rollback_body', 'Roll back the migration. That returns all migrated data to the state from before your first migration. Your %s data was never touched.' ),
                'wizard_rollback_body_generic' => tamrank__( 'wizard_rollback_body_generic', 'Roll back the migration. That returns all migrated data to the state from before your first migration. Your original data was never touched.' ),
                'wizard_rollback_keep'       => tamrank__( 'wizard_rollback_keep', 'Keep %s active until you are happy with the result. The backup is kept until you use it.' ),
                'wizard_rollback_keep_generic' => tamrank__( 'wizard_rollback_keep_generic', 'Keep your old SEO plugin active until you are happy with the result. The backup is kept until you use it.' ),
                'wizard_rollback_running'    => tamrank__( 'wizard_rollback_running', 'Rolling back...' ),
                'wizard_rollback_done'       => tamrank__( 'wizard_rollback_done', 'The migration has been rolled back. All migrated data is back in the state from before your first migration.' ),
                'wizard_rollback_failed'     => tamrank__( 'wizard_rollback_failed', 'The rollback did not complete. Try again, or do it later from Settings, Import and Export.' ),
                // Step 1 "Startfoto" — the three heading pairs the panel swaps
                // between, plus the pieces JS writes into the sheet.
                'wizard_scan_value_none'     => tamrank__( 'wizard_scan_value_none', 'none' ),
                'wizard_scan_duration_seconds' => tamrank__( 'wizard_scan_duration_seconds', 'about %s seconds' ),
                'wizard_scan_duration_minutes' => tamrank__( 'wizard_scan_duration_minutes', 'about %s minutes' ),
                'wizard_no_seo_data'         => tamrank__( 'wizard_no_seo_data', 'No existing SEO data found to migrate. You can start with a clean slate.' ),
                'wizard_no_migration_needed' => tamrank__( 'wizard_no_migration_needed', 'Nothing to migrate. TamRank will start fresh.' ),
                'wizard_skip_migration'      => tamrank__( 'wizard_skip_migration', 'Continue without migration' ),
                'wizard_zero_errors'         => tamrank__( 'wizard_zero_errors', '0 errors' ),
                // Wizard-redesign variant 2 "Verhuisplan": the merged plan step,
                // the per-category one-liners and the migration log.
                'wizard_start_migration'     => tamrank__( 'wizard_start_migration', 'Start migration' ),
                'wizard_scan_failed_text'    => tamrank__( 'wizard_scan_failed_text', 'We could not scan your site just now. That may be a temporary glitch; try again, or simply continue.' ),
                'wizard_plan_title'          => tamrank__( 'wizard_plan_title', 'What do we take along from %s?' ),
                'wizard_plan_title_generic'  => tamrank__( 'wizard_plan_title_generic', 'What do we take along?' ),
                'wizard_plan_subtitle'       => tamrank__( 'wizard_plan_subtitle', 'Tick what TamRank moves over. Your %s data stays where it is.' ),
                'wizard_plan_subtitle_generic' => tamrank__( 'wizard_plan_subtitle_generic', 'Tick what TamRank moves over. Your existing SEO data stays where it is.' ),
                'wizard_plan_found'          => tamrank__( 'wizard_plan_found', 'Found in %s' ),
                'wizard_plan_found_generic'  => tamrank__( 'wizard_plan_found_generic', 'Found in your old SEO plugin' ),
                'wizard_plan_select_all'     => tamrank__( 'wizard_plan_select_all', 'Select everything' ),
                'wizard_plan_select_none'    => tamrank__( 'wizard_plan_select_none', 'Select nothing' ),
                'wizard_plan_total_field'    => tamrank__( 'wizard_plan_total_field', '%s field' ),
                'wizard_plan_total_fields'   => tamrank__( 'wizard_plan_total_fields', '%s fields' ),
                'wizard_plan_start_disabled' => tamrank__( 'wizard_plan_start_disabled', 'Pick what you want to take along first' ),
                'wizard_cat_what_meta_titles' => tamrank__( 'wizard_cat_what_meta_titles', 'The SEO title that shows up in Google.' ),
                'wizard_cat_what_meta_descriptions' => tamrank__( 'wizard_cat_what_meta_descriptions', 'The text under the title in the search results.' ),
                'wizard_cat_what_focus_keywords' => tamrank__( 'wizard_cat_what_focus_keywords', 'The word a page is meant to rank for.' ),
                'wizard_cat_what_robots'     => tamrank__( 'wizard_cat_what_robots', 'Pages you kept out of the search results.' ),
                'wizard_cat_what_canonical'  => tamrank__( 'wizard_cat_what_canonical', 'The URL that counts as the original.' ),
                'wizard_cat_what_social'     => tamrank__( 'wizard_cat_what_social', 'Title and image for Facebook and LinkedIn.' ),
                'wizard_cat_what_schema'     => tamrank__( 'wizard_cat_what_schema', 'The page type you pass on to search engines.' ),
                'wizard_cat_what_redirects'  => tamrank__( 'wizard_cat_what_redirects', 'Forwards from old URLs to new ones.' ),
                'wizard_cat_what_robots_txt' => tamrank__( 'wizard_cat_what_robots_txt', 'The rules that tell search engines where they may go.' ),
                'wizard_cat_what_sitemap_config' => tamrank__( 'wizard_cat_what_sitemap_config', 'Pages you kept out of the sitemap.' ),
                'wizard_cat_what_site_identity' => tamrank__( 'wizard_cat_what_site_identity', 'Your brand name, logo and social profiles.' ),
                // The run counts in the unit the batch API reports: one per
                // migrated post, plus one per imported redirect and setting.
                // A field total would never fill up, because a single post can
                // carry several fields.
                'wizard_mig_progress'        => tamrank__( 'wizard_mig_progress', '%s items moved over.' ),
                'wizard_mig_progress_one'    => tamrank__( 'wizard_mig_progress_one', '%s item moved over.' ),
                'wizard_mig_backup_line'     => tamrank__( 'wizard_mig_backup_line', 'Backup made. It is kept until you use it.' ),
                'wizard_mig_all_done'        => tamrank__( 'wizard_mig_all_done', 'Done. %s items moved over.' ),
                'wizard_mig_nothing_new'     => tamrank__( 'wizard_mig_nothing_new', 'Done. Everything was already in TamRank, so nothing had to move.' ),
                'wizard_mig_done_notes'      => tamrank__( 'wizard_mig_done_notes', 'Done, with remarks. %s items moved over; the report below says what did not come along.' ),
                'wizard_mig_batch_skipped'   => tamrank__( 'wizard_mig_batch_skipped', 'Group skipped. Those items were not moved over.' ),
                'wizard_mig_stopped'         => tamrank__( 'wizard_mig_stopped', 'Stopped. %s items moved over; what was already moved over stays put, and so does the backup.' ),
                'wizard_done_incomplete_title' => tamrank__( 'wizard_done_incomplete_title', 'The migration did not finish.' ),
                'wizard_done_incomplete_sub' => tamrank__( 'wizard_done_incomplete_sub', '%s items were moved over. The log says where the migration stopped; the backup is still there.' ),
                'wizard_done_skipped_sub'    => tamrank__( 'wizard_done_skipped_sub', '%s items were moved over. One or more groups were skipped; the log says which.' ),
                'wizard_done_results_head'   => tamrank__( 'wizard_done_results_head', 'Moved over to TamRank' ),
                'wizard_done_results_head_plan' => tamrank__( 'wizard_done_results_head_plan', 'From the moving plan' ),
                'wizard_done_nothing_new_sub' => tamrank__( 'wizard_done_nothing_new_sub', 'Everything in the moving plan was already in TamRank, so nothing was moved over this time.' ),
                // Redirects the source keeps in a table this migration does not read.
                'wizard_redirects_left'      => tamrank__( 'wizard_redirects_left', '%d redirects found in %s. TamRank does not take redirects over from it, so they stay where they are.' ),
                'wizard_redirects_left_generic' => tamrank__( 'wizard_redirects_left_generic', '%d redirects found in your old SEO plugin. TamRank does not take redirects over from it, so they stay where they are.' ),
                'wizard_redirects_left_one'  => tamrank__( 'wizard_redirects_left_one', '1 redirect found in %s. TamRank does not take redirects over from it, so that one stays where it is.' ),
                'wizard_redirects_left_one_generic' => tamrank__( 'wizard_redirects_left_one_generic', '1 redirect found in your old SEO plugin. TamRank does not take redirects over from it, so that one stays where it is.' ),
                // Redirects this migration DOES import that leave the site.
                'wizard_redirects_external'  => tamrank__( 'wizard_redirects_external', '%d of the redirects being imported point at another domain. TamRank imports them and they keep working - worth a look if you did not expect traffic to leave your site.' ),
                'wizard_redirects_external_one' => tamrank__( 'wizard_redirects_external_one', '1 of the redirects being imported points at another domain. TamRank imports it and it keeps working - worth a look if you did not expect traffic to leave your site.' ),
                'wizard_done_rolledback_title' => tamrank__( 'wizard_done_rolledback_title', 'The migration has been rolled back.' ),
                'wizard_done_rolledback_subtitle' => tamrank__( 'wizard_done_rolledback_subtitle', 'There is no migrated data left in TamRank. Your %s data was never touched.' ),
                'wizard_done_rolledback_subtitle_generic' => tamrank__( 'wizard_done_rolledback_subtitle_generic', 'There is no migrated data left in TamRank. Your original data was never touched.' ),
                'tip_missing_desc'           => tamrank__( 'tip_missing_desc', 'You have %s pages without a meta description. Add them quickly in the editor.' ),
                'tip_duplicate_titles'       => tamrank__( 'tip_duplicate_titles', 'There are %s pages with the same title. Unique titles help search engines distinguish your pages better.' ),
                'tip_schema_pct'             => tamrank__( 'tip_schema_pct', 'Only %s% of your pages have schema markup. Enable automatic schema detection in TamRank settings.' ),
                'ready_to_use'               => tamrank__( 'ready_to_use', 'TamRank is ready to use!' ),
                'ready_to_use_desc'          => tamrank__( 'ready_to_use_desc', 'Your site has been scanned and TamRank is ready to improve your SEO.' ),
                // robots.txt import: provenance, physical-file warning and report.
                'robots_physical_file'       => tamrank__( 'robots_physical_file', 'There is a robots.txt file in your site root (%s). As long as it is there, it beats anything TamRank sets. We import its contents, but we do not delete the file; that is yours to do over FTP or your hosting panel.' ),
                'robots_origin_live'         => tamrank__( 'robots_origin_live', 'We read the robots.txt your site is serving right now, so everything your old plugin added is included.' ),
                'robots_origin_options'      => tamrank__( 'robots_origin_options', 'We could not fetch the live robots.txt (%s), so this version is reconstructed from your old plugin\'s settings. Check it before you import.' ),
                'robots_origin_nonpublic'    => tamrank__( 'robots_origin_nonpublic', 'Your site is set to discourage search engines, which makes the live robots.txt unrepresentative. This version comes from your old plugin\'s settings.' ),
                'robots_origin_rankmath'     => tamrank__( 'robots_origin_rankmath', 'Rank Math has not finished its setup, so it is not serving its own robots.txt yet. This version comes straight from its settings.' ),
                'robots_sitemap_rewritten'   => tamrank__( 'robots_sitemap_rewritten', 'The Sitemap: line now points at TamRank\'s sitemap. A Sitemap: line pointing at another website is left alone.' ),
                'robots_search_appearance'   => tamrank__( 'robots_search_appearance', 'These rules come from All in One SEO\'s Search Appearance / crawl cleanup, not from its robots.txt editor:' ),
                'report_robots_written'      => tamrank__( 'report_robots_written', 'robots.txt imported.' ),
                'report_robots_kept'         => tamrank__( 'report_robots_kept', 'robots.txt skipped: your own version was kept.' ),
                'report_robots_nothing'      => tamrank__( 'report_robots_nothing', 'robots.txt skipped: there was nothing to import.' ),
                'report_sitemap_written'     => tamrank__( 'report_sitemap_written', 'Sitemap settings imported for %d content types.' ),
                'report_sitemap_excluded'    => tamrank__( 'report_sitemap_excluded', 'Kept out of the sitemap: %s' ),
                'report_sitemap_disabled'    => tamrank__( 'report_sitemap_disabled', 'Your sitemap was switched off in your old plugin, so it is switched off in TamRank too.' ),
            ),
        ) );
    }

    /* ------------------------------------------------------------------
     * AJAX: Set language
     * ----------------------------------------------------------------*/

    public function ajax_set_language() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => 'Permission denied', 'code' => 'permission_denied' ) );
        }

        $language  = isset( $_POST['language'] ) ? sanitize_text_field(wp_unslash($_POST['language'])) : '';
        $available = array( 'en', 'nl', 'de', 'fr' );

        if ( ! in_array( $language, $available, true ) ) {
            wp_send_json_error( array( 'message' => 'Invalid language', 'code' => 'invalid_language' ) );
        }

        $settings             = get_option( 'tam_rank_general_settings', array() );
        $settings['language'] = $language;
        update_option( 'tam_rank_general_settings', $settings );

        wp_send_json_success( array( 'language' => $language ) );
    }

    /* ------------------------------------------------------------------
     * AJAX: Skip wizard
     * ----------------------------------------------------------------*/

    public function ajax_skip() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        update_option( self::OPTION_COMPLETE, 'skipped' );
        wp_send_json_success( array( 'redirect' => admin_url( 'admin.php?page=tam-rank-dashboard' ) ) );
    }

    /* ------------------------------------------------------------------
     * AJAX: Detect SEO plugin
     * ----------------------------------------------------------------*/

    public function ajax_detect() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        if ( ! class_exists( 'TamRank_SEO_Scanner' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-seo-scanner.php';
        }

        $scanner = new TamRank_SEO_Scanner();
        $all     = $scanner->detect_all_seo_plugins();
        $first   = ! empty( $all ) ? $all[0] : array( 'plugin' => null, 'plugin_name' => '', 'data_count' => 0 );

        // Back-compat shape (plugin/plugin_name/data_count) plus the full
        // source list for the wizard's source-choice UI.
        wp_send_json_success( array_merge( $first, array( 'sources' => $all ) ) );
    }

    /* ------------------------------------------------------------------
     * AJAX: Run SEO scan
     * ----------------------------------------------------------------*/

    public function ajax_scan() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        if ( ! class_exists( 'TamRank_SEO_Scanner' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-seo-scanner.php';
        }

        $scanner = new TamRank_SEO_Scanner();

        // Fail-closed source validation: an invalid or undetected source is
        // an error, never a silent fallback to auto-detection.
        $source    = isset( $_POST['source'] ) ? sanitize_text_field( wp_unslash( $_POST['source'] ) ) : '';
        $available = array( 'yoast', 'rankmath', 'aioseo' );
        if ( '' !== $source ) {
            if ( ! in_array( $source, $available, true ) ) {
                wp_send_json_error( array( 'message' => tamrank__( 'error_invalid_source', 'Invalid migration source.' ), 'code' => 'invalid_source' ) );
            }
            $detected = wp_list_pluck( $scanner->detect_all_seo_plugins(), 'plugin' );
            if ( ! in_array( $source, $detected, true ) ) {
                wp_send_json_error( array( 'message' => tamrank__( 'error_source_not_detected', 'No data found for this source.' ), 'code' => 'source_not_detected' ) );
            }
        }

        // Onboarding runs once: always produce fresh results so newly added
        // SEO data is picked up instead of a stale 1-hour cache (per-source key).
        delete_transient( TamRank_SEO_Scanner::CACHE_KEY . ( $source ? '_' . $source : '' ) );

        wp_send_json_success( $scanner->run_scan( $source ? $source : null ) );
    }

    /* ------------------------------------------------------------------
     * AJAX: Run migration batch
     * ----------------------------------------------------------------*/

    /**
     * AJAX: preview of the robots.txt and sitemap import.
     *
     * Separate from the scan on purpose: this one performs a loopback HTTP request with
     * a ten second timeout, and the scan should not pay for that on every run. The
     * wizard asks for it only when the robots.txt category is actually on the table.
     *
     * @return void
     */
    public function ajax_settings_preview() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => tamrank__( 'error_permission_denied', 'You do not have permission for this action.' ), 'code' => 'permission_denied' ) );
        }

        $source = isset( $_POST['source'] ) ? sanitize_text_field( wp_unslash( $_POST['source'] ) ) : '';
        if ( ! in_array( $source, array( 'yoast', 'rankmath', 'aioseo' ), true ) ) {
            wp_send_json_error( array( 'message' => tamrank__( 'error_invalid_source', 'Invalid migration source.' ), 'code' => 'invalid_source' ) );
        }

        if ( ! class_exists( 'TamRank_Settings_Migrator' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-settings-migrator.php';
        }

        $migrator = new TamRank_Settings_Migrator( $source );
        $preview  = $migrator->preview();

        wp_send_json_success( array(
            'robots'  => $preview['robots'],
            'sitemap' => array(
                'enabled'  => $preview['sitemap']['enabled'],
                'excluded' => $preview['sitemap']['excluded'],
            ),
        ) );
    }

    public function ajax_migrate() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        if ( ! class_exists( 'TamRank_Migration_Manager' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-migration-manager.php';
        }
        if ( ! class_exists( 'TamRank_SEO_Scanner' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-seo-scanner.php';
        }

        $categories = isset( $_POST['categories'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['categories'] ) ) : array();
        $offset     = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0;

        // Fail-closed source validation: an invalid or undetected source is
        // an error, never a silent fallback to auto-detection. The (heavier)
        // detection check runs on the FIRST batch only — later batches of the
        // same run trust it, and an unvalidated source still hits the
        // migrator's own table/postmeta gates (empty batches, no writes).
        $scanner   = new TamRank_SEO_Scanner();
        $source    = isset( $_POST['source'] ) ? sanitize_text_field( wp_unslash( $_POST['source'] ) ) : '';
        $available = array( 'yoast', 'rankmath', 'aioseo' );
        if ( '' !== $source ) {
            if ( ! in_array( $source, $available, true ) ) {
                wp_send_json_error( array( 'message' => tamrank__( 'error_invalid_source', 'Invalid migration source.' ), 'code' => 'invalid_source' ) );
            }
            if ( 0 === $offset ) {
                $detected = wp_list_pluck( $scanner->detect_all_seo_plugins(), 'plugin' );
                if ( ! in_array( $source, $detected, true ) ) {
                    wp_send_json_error( array( 'message' => tamrank__( 'error_source_not_detected', 'No data found for this source.' ), 'code' => 'source_not_detected' ) );
                }
            }
        }

        // Conflict choice for an already-edited robots.txt: 'keep' (the default) or
        // 'replace'. Anything else is treated as keep, so a malformed value can never
        // silently overwrite what the user typed.
        $robots_conflict = isset( $_POST['robots_conflict'] ) ? sanitize_key( wp_unslash( $_POST['robots_conflict'] ) ) : 'keep';
        if ( ! in_array( $robots_conflict, array( 'keep', 'replace' ), true ) ) {
            $robots_conflict = 'keep';
        }

        $manager = new TamRank_Migration_Manager();
        $result  = $manager->run_batch(
            $categories,
            $offset,
            $source ? $source : null,
            array( 'robots_conflict' => $robots_conflict )
        );

        wp_send_json_success( $result );
    }

    /* ------------------------------------------------------------------
     * AJAX: Rollback migration
     * ----------------------------------------------------------------*/

    public function ajax_rollback() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        if ( ! class_exists( 'TamRank_Rollback' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-rollback.php';
        }

        $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0;

        $rollback = new TamRank_Rollback();
        $result   = $rollback->run_batch( $offset );

        wp_send_json_success( $result );
    }

    /* ------------------------------------------------------------------
     * AJAX: Mark onboarding complete
     * ----------------------------------------------------------------*/

    public function ajax_complete() {
        check_ajax_referer( 'tamrank_onboarding', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        // Email tips opt-in — only register if user actively checked the box.
        // Existing opt-in (with synced_at) is never overwritten so an accidental
        // unchecked second-run cannot wipe a previously-confirmed consent.
        $accept = ! empty( $_POST['accept_email_tips'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['accept_email_tips'] ) );

        if ( $accept ) {
            $existing       = get_option( self::OPTION_EMAIL_OPTIN );
            $already_synced = is_array( $existing ) && ! empty( $existing['synced_at'] );

            if ( ! $already_synced ) {
                // Persona track — optional, two-way A/B (owner/dev), empty = skip → VPS defaults to A.
                // PRO personas (in-house/freelance) are detected post-purchase, not in the wizard.
                // VPS email_optins still accepts the A/B/C/D superset, so A/B is a valid subset.
                $persona_raw   = isset( $_POST['persona_track'] ) ? sanitize_text_field( wp_unslash( $_POST['persona_track'] ) ) : '';
                $persona_track = in_array( $persona_raw, array( 'A', 'B' ), true ) ? $persona_raw : '';

                update_option( self::OPTION_EMAIL_OPTIN, array(
                    'accepted_at'   => time(),
                    'email'         => sanitize_email( get_bloginfo( 'admin_email' ) ),
                    'language'      => tamrank_get_language(),
                    'persona_track' => $persona_track,
                    'synced_at'     => null,
                ), false );
            }
        }

        update_option( self::OPTION_COMPLETE, 'completed' );
        wp_send_json_success();
    }

    /* ------------------------------------------------------------------
     * Email opt-in sync loop (admin_init)
     *
     * Plugin → VPS sync for the T04 setup-tips opt-in. Retries each admin
     * page load (throttled to once per hour) until POST /v1/sites/email-optin
     * returns 200. Sets `synced_at` so the next call is a no-op.
     *
     * Endpoint spec: see Plugin/API/Email-Optin-Endpoint.md in the planning
     * vault. Endpoint may not exist yet (returns 404) — that's fine, we keep
     * the opt-in locally and retry on the next admin page load.
     * ----------------------------------------------------------------*/

    public function maybe_sync_email_optin() {
        // Cheap exits first.
        if ( ! is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
            return;
        }

        // Only the consenting administrator role triggers the sync; the opt-in
        // record is authored by a manage_options user during onboarding.
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $optin = get_option( self::OPTION_EMAIL_OPTIN );

        // Nothing to sync, or already synced.
        if ( ! is_array( $optin ) || ! empty( $optin['synced_at'] ) ) {
            return;
        }

        // Throttle: only attempt sync once per hour to avoid hammering the API
        // on every admin page load while the endpoint is still pending.
        if ( get_transient( self::TRANSIENT_OPTIN_SYNC_LOCK ) ) {
            return;
        }
        set_transient( self::TRANSIENT_OPTIN_SYNC_LOCK, 1, HOUR_IN_SECONDS );

        // Need an api_key — first plugin activation may not have one yet
        // (LicenseManager::register_site() runs on first PRO/GSC interaction).
        // Read through the centralised cached-option seam (request-scoped cache
        // + invalidation, and the single choke point for any future at-rest
        // encryption), with a defensive get_option() fallback for load order.
        $api_key = function_exists( 'tam_rank_cached_option' )
            ? tam_rank_cached_option( 'tam_rank_api_key', '' )
            : get_option( 'tam_rank_api_key', '' );
        if ( empty( $api_key ) ) {
            return;
        }

        $email    = ! empty( $optin['email'] )    ? sanitize_email( $optin['email'] )         : sanitize_email( get_bloginfo( 'admin_email' ) );
        $language = ! empty( $optin['language'] ) ? sanitize_text_field( $optin['language'] ) : tamrank_get_language();

        if ( ! is_email( $email ) ) {
            return;
        }

        $response = wp_remote_post( 'https://api.tamrank.com/v1/sites/email-optin', array(
            'timeout' => 10,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Accept'        => 'application/json',
                'Authorization' => 'Bearer ' . $api_key,
            ),
            'body' => wp_json_encode( array(
                'email'          => $email,
                'language'       => $language,
                'persona_track'  => ! empty( $optin['persona_track'] ) ? $optin['persona_track'] : null,
                'source'         => 'plugin_onboarding',
                'site_url'       => home_url(),
                'plugin_version' => defined( 'TAM_RANK_VERSION' ) ? TAM_RANK_VERSION : '',
            ) ),
        ) );

        // Soft-fail on any network/HTTP error — the throttle lock keeps us
        // from retrying for an hour, then we'll try again automatically.
        if ( is_wp_error( $response ) ) {
            return;
        }

        $status = wp_remote_retrieve_response_code( $response );
        if ( 200 !== $status && 201 !== $status ) {
            return;
        }

        $optin['synced_at'] = time();
        update_option( self::OPTION_EMAIL_OPTIN, $optin, false );

        // Free the throttle now that we've succeeded.
        delete_transient( self::TRANSIENT_OPTIN_SYNC_LOCK );
    }

    /* ------------------------------------------------------------------
     * Dashboard banners
     * ----------------------------------------------------------------*/

    /** @var bool|null Memoized migration-backup lookup, one request long. */
    private $migration_backup_cache = null;

    public function maybe_show_banner() {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

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

        $status = get_option( self::OPTION_COMPLETE );

        // A second SEO plugin writing to the same pages is the most urgent
        // thing this screen can say, so it takes the notice slot from both
        // banners below - there is never more than one at a time. Dismissing it
        // hands the slot back to whichever banner would otherwise have shown.
        if ( $this->maybe_show_coexistence_banner( $status ) ) {
            return;
        }

        // Banner: wizard not completed
        if ( ! $status ) {
            $wizard_url = esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
            echo '<div class="tamrank-onboarding-banner" style="display:flex;align-items:center;gap:16px;padding:16px 20px;margin:10px 0;background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,0.05);border:1px solid #e2e8f0;">';
            echo '<div style="width:40px;height:40px;border-radius:10px;background:#10b981;display:flex;align-items:center;justify-content:center;flex-shrink:0;">';
            echo '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
            echo '</div>';
            echo '<div style="flex:1;">';
            echo '<strong style="font-size:14px;color:#0f172a;">' . esc_html( tamrank__( 'onboarding_welcome', 'Welcome to TamRank!' ) ) . '</strong> ';
            echo '<span style="font-size:14px;color:#64748b;">' . esc_html( tamrank__( 'onboarding_complete_wizard', 'Complete the setup wizard to get the most out of TamRank.' ) ) . '</span>';
            echo '</div>';
            echo '<a href="' . esc_url($wizard_url) . '" style="display:inline-flex;align-items:center;gap:6px;padding:0 20px;height:40px;background:#10b981;color:#fff;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;white-space:nowrap;transition:background 200ms;">';
            echo esc_html( tamrank__( 'onboarding_start_wizard', 'Start Setup Wizard' ) ) . '</a>';
            echo '</div>';
            return;
        }

        // Banner: migration completed (rollback available)
        if ( $status === 'completed' ) {
            if ( $this->has_migration_backup() ) {
                $dismissed = get_option( 'tamrank_migration_banner_dismissed' );
                if ( ! $dismissed ) {
                    $dismiss_nonce = wp_create_nonce( 'tamrank_dismiss_migration_banner' );
                    echo '<div class="notice notice-success tamrank-migration-banner tr-notice-card tr-notice-card--success is-dismissible" data-dismiss-nonce="' . esc_attr( $dismiss_nonce ) . '">';
                    echo '<span class="tr-notice-card__icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>';
                    echo '<div class="tr-notice-card__content">';
                    echo '<p class="tr-notice-card__body">' . esc_html( tamrank__( 'onboarding_migration_complete', 'Migration complete! Your SEO data has been successfully transferred to TamRank.' ) ) . '</p>';
                    // De setup-wizard consumeert geen action=rollback; de werkende rollback-UI is de kaart op Settings > Import/Export (hash-gestuurde tab).
                    echo '<p class="tr-notice-card__actions"><a class="tr-notice-card__link" href="' . esc_url( admin_url( 'admin.php?page=tam-rank-settings#import-export' ) ) . '">';
                    echo esc_html( tamrank__( 'onboarding_rollback_migration', 'Roll back migration' ) ) . '</a></p>';
                    echo '</div></div>';
                    $this->add_banner_dismiss_script( 'tamrank-migration-banner', 'tamrank_dismiss_migration_banner' );
                }
            }
        }
    }

    /**
     * Notice: a second SEO plugin is active next to TamRank.
     *
     * Two states, and the caller prints nothing when either of them shows:
     *
     *   1. the migration is not done yet  -> "bring your settings over first"
     *   2. the migration is done          -> "you can switch the other one off"
     *
     * Both carry their own dismissal flag on purpose. Someone who waves away
     * "you have two of these, go migrate" is exactly the person who still needs
     * to hear "the migration is done, you can switch the other one off now".
     * A dismissed notice never comes back, and the banner it displaced takes
     * the slot again from that moment on.
     *
     * @param string|false $status Value of OPTION_COMPLETE.
     * @return bool True when a notice was printed.
     */
    private function maybe_show_coexistence_banner( $status ) {
        if ( ! class_exists( 'TamRank_SEO_Scanner' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-seo-scanner.php';
        }

        $scanner = new TamRank_SEO_Scanner();
        $active  = $scanner->detect_active_seo_plugins();

        if ( empty( $active ) ) {
            return false;
        }

        // One name, the first in detection priority order. A comma list would
        // have to read well in four languages; the extra sentence says "there
        // are more" instead.
        $name  = $active[0]['plugin_name'];
        $extra = count( $active ) > 1
            ? ' ' . tamrank__( 'coex_more_active', 'More than one other SEO plugin is active on this site.' )
            : '';

        if ( $status === 'completed' ) {
            if ( get_option( self::OPTION_COEX_STILL_ACTIVE_DISMISSED ) ) {
                return false;
            }

            // The button opens the Plugins screen filtered on the other
            // plugin's name; it deactivates nothing. Switching a competitor off
            // from inside our own notice is not ours to do, and a migration
            // that missed something would leave the site without meta tags.
            $plugins_url   = add_query_arg( 's', rawurlencode( $name ), admin_url( 'plugins.php' ) );
            $dismiss_nonce = wp_create_nonce( 'tamrank_dismiss_coex_still_active' );

            echo '<div class="notice notice-warning tamrank-coex-still-active tr-notice-card tr-notice-card--warning is-dismissible" data-dismiss-nonce="' . esc_attr( $dismiss_nonce ) . '">';
            echo '<span class="tr-notice-card__icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg></span>';
            echo '<div class="tr-notice-card__content">';
            echo '<p class="tr-notice-card__title"><strong>' . esc_html( sprintf( tamrank__( 'coex_still_active_title', 'Your migration is done, but %s is still running' ), $name ) ) . '</strong></p>';
            echo '<p class="tr-notice-card__body">' . esc_html( sprintf( tamrank__( 'coex_still_active_body', 'Your titles, descriptions and redirects are now in TamRank. As long as %s stays active, both plugins keep writing to the same pages. Switching it off leaves one answer in place.' ), $name ) . $extra ) . '</p>';
            echo '<p class="tr-notice-card__actions"><a href="' . esc_url( $plugins_url ) . '" class="tr-btn tr-btn--primary tr-btn--sm">';
            echo esc_html( tamrank__( 'coex_still_active_cta', 'Go to Plugins' ) ) . '</a>';
            if ( $this->has_migration_backup() ) {
                echo ' <a href="' . esc_url( admin_url( 'admin.php?page=tam-rank-settings#import-export' ) ) . '" class="tr-notice-card__link tamrank-coex-rollback">';
                echo esc_html( tamrank__( 'onboarding_rollback_migration', 'Roll back migration' ) ) . '</a>';
            }
            echo '</p></div></div>';

            $this->add_banner_dismiss_script( 'tamrank-coex-still-active', 'tamrank_dismiss_coex_still_active' );

            return true;
        }

        // Not completed: empty (wizard never finished) or 'skipped'. The
        // skipped site is the likeliest one to still be running two plugins,
        // and until now it saw nothing at all.
        if ( get_option( self::OPTION_COEX_MIGRATE_FIRST_DISMISSED ) ) {
            return false;
        }

        $wizard_url    = admin_url( 'admin.php?page=' . self::PAGE_SLUG );
        $dismiss_nonce = wp_create_nonce( 'tamrank_dismiss_coex_migrate_first' );

        echo '<div class="notice notice-warning tamrank-coex-migrate-first tr-notice-card tr-notice-card--info is-dismissible" data-dismiss-nonce="' . esc_attr( $dismiss_nonce ) . '">';
        echo '<span class="tr-notice-card__icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>';
        echo '<div class="tr-notice-card__content">';
        echo '<p class="tr-notice-card__title"><strong>' . esc_html( sprintf( tamrank__( 'coex_migrate_first_title', '%s is also active' ), $name ) ) . '</strong></p>';
        echo '<p class="tr-notice-card__body">' . esc_html( tamrank__( 'coex_migrate_first_body', 'Two SEO plugins write your meta tags, your sitemap and your structured data at the same time. Search engines then find two answers to the same question. Bring your settings over to TamRank first, and you can switch the other one off with confidence.' ) . $extra ) . '</p>';
        echo '<p class="tr-notice-card__actions"><a href="' . esc_url( $wizard_url ) . '" class="tr-btn tr-btn--primary tr-btn--sm">';
        echo esc_html( tamrank__( 'coex_migrate_first_cta', 'Start the migration' ) ) . '</a></p>';
        echo '</div></div>';

        $this->add_banner_dismiss_script( 'tamrank-coex-migrate-first', 'tamrank_dismiss_coex_migrate_first' );

        return true;
    }

    /**
     * Does this site still hold a migration backup to roll back to?
     *
     * @return bool
     */
    private function has_migration_backup() {
        if ( $this->migration_backup_cache !== null ) {
            return $this->migration_backup_cache;
        }

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on core postmeta table (existence check for migration-backup rows); admin-context read, caching not applicable
        $has_backup = $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_tamrank_migration_backup' LIMIT 1"
        );

        $this->migration_backup_cache = (bool) ( $has_backup || ! empty( get_option( 'tamrank_migration_redirect_ids', array() ) ) );

        return $this->migration_backup_cache;
    }

    /**
     * Persist a notice's dismissal when the user clicks the core "X".
     *
     * Enqueued instead of echoed inline (wp.org compliance); the dashboard
     * screen has the footer-loaded tam-rank-admin handle, and the dismissal
     * nonce travels via the banner's data-dismiss-nonce attribute.
     *
     * @param string $banner_class CSS class on the notice wrapper.
     * @param string $ajax_action  admin-ajax action that stores the dismissal.
     */
    private function add_banner_dismiss_script( $banner_class, $ajax_action ) {
        wp_add_inline_script( 'tam-rank-admin', '(function(){var b=document.querySelector(".' . esc_js( $banner_class ) . '");if(!b){return;}b.addEventListener("click",function(e){if(!e.target.classList.contains("notice-dismiss")){return;}var d=new FormData();d.append("action","' . esc_js( $ajax_action ) . '");d.append("nonce",b.getAttribute("data-dismiss-nonce"));fetch(window.ajaxurl,{method:"POST",credentials:"same-origin",body:d});});})();' );
    }

    /**
     * Persist dismissal of the migration-complete banner (nonce + capability gated).
     */
    public function ajax_dismiss_migration_banner() {
        $this->store_banner_dismissal( 'tamrank_dismiss_migration_banner', 'tamrank_migration_banner_dismissed' );
    }

    /**
     * Persist dismissal of the "migrate first" coexistence notice.
     */
    public function ajax_dismiss_coex_migrate_first() {
        $this->store_banner_dismissal( 'tamrank_dismiss_coex_migrate_first', self::OPTION_COEX_MIGRATE_FIRST_DISMISSED );
    }

    /**
     * Persist dismissal of the "the other plugin still runs" coexistence notice.
     */
    public function ajax_dismiss_coex_still_active() {
        $this->store_banner_dismissal( 'tamrank_dismiss_coex_still_active', self::OPTION_COEX_STILL_ACTIVE_DISMISSED );
    }

    /**
     * Shared body of the dismissal endpoints (nonce + capability gated).
     *
     * @param string $nonce_action Nonce action the banner carries.
     * @param string $option       Option that records the dismissal.
     */
    private function store_banner_dismissal( $nonce_action, $option ) {
        if ( ! check_ajax_referer( $nonce_action, 'nonce', false ) || ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
        }
        update_option( $option, 1 );
        wp_send_json_success();
    }
}
