/**
 * TamRank Onboarding Wizard
 *
 * Handles step navigation, AJAX detection/scan/migration, progress, the
 * restrained peak moment on the report, and accessibility.
 *
 * Layout: variant 2 "Verhuisplan" (redesign 21 Aug 2026). Four steps instead
 * of five: the site scan is no longer a screen of its own but the side track
 * of the selection it informs. Everything below the AJAX helper is unchanged
 * in contract: same actions, same payloads, same batching.
 */
(function () {
    'use strict';

    /* ------------------------------------------------------------------
     * State
     * ----------------------------------------------------------------*/

    var cfg = window.tamrankOnboarding || {};

    // Steps: 0 = Detection, 1 = Moving plan, 2 = Migration, 3 = Done.
    var STEP_DETECT  = 0;
    var STEP_PLAN    = 1;
    var STEP_MIGRATE = 2;
    var STEP_DONE    = 3;

    var state = {
        currentStep: 0,
        totalSteps: 4,
        detection: null,
        languageSelected: cfg.currentLanguage || 'en',
        scanData: null,
        scanFailed: false,
        migrationOffset: 0,
        // What the server reported back, in its own unit: one per migrated
        // post, plus one per imported redirect and setting. There is no
        // matching total on this side. The plan counts fields (posts that carry
        // that field, per category) and a single post can carry several, so a
        // field total can not measure post progress - and how many posts sit
        // behind a set of categories is only known server-side.
        migrationDone: 0,
        migrationErrors: [],
        // Batches the user stepped over after a failure, and whether the run
        // was cut short. Both keep the closing line from claiming a clean run.
        migrationSkipped: 0,
        // Values the server confirmed it wrote, and source items it left behind
        // on purpose. Both are counted in items, not in server messages.
        migrationWritten: 0,
        migrationSkippedItems: 0,
        migrationStopped: false,
        migrationNotes: [],
        migrationStartTime: 0,
        migrationBatches: 0,
        selectedCategories: [],
        isTransitioning: false,
        chosenFlow: null,
        selectedSource: null,
        // Debounce handle for the whole-row trim of the category list.
        categoryFitTimer: 0
    };
    var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    /**
     * Categories the migration manager handles entirely on the first batch
     * (redirects, robots.txt and the sitemap configuration are site-wide, not
     * post-based). Only these can honestly be reported as finished before the
     * run is over; every post-based category is processed across all batches
     * at once, so the moving list says "busy" for those until done arrives.
     */
    var SITEWIDE_CATEGORIES = ['redirects', 'robots_txt', 'sitemap_config', 'site_identity'];

    /**
     * Mirrors TamRank_Migration_Manager::BATCH_SIZE. Only used to step over a
     * batch the user chose to skip; the server keeps deciding when a run is
     * done, so a drift here delays the end of a skip, it never fakes one.
     */
    var MIGRATION_BATCH_SIZE = 50;

    /**
     * One plain-language line per category, so the choice explains itself
     * without a tooltip (deliberate deviation from Rank Math; see the
     * competitor study, lesson 3).
     */
    var CATEGORY_WHAT_KEYS = {
        meta_titles:       'wizard_cat_what_meta_titles',
        meta_descriptions: 'wizard_cat_what_meta_descriptions',
        focus_keywords:    'wizard_cat_what_focus_keywords',
        robots:            'wizard_cat_what_robots',
        canonical:         'wizard_cat_what_canonical',
        social:            'wizard_cat_what_social',
        schema:            'wizard_cat_what_schema',
        redirects:         'wizard_cat_what_redirects',
        robots_txt:        'wizard_cat_what_robots_txt',
        sitemap_config:    'wizard_cat_what_sitemap_config',
        site_identity:     'wizard_cat_what_site_identity'
    };

    var ICON_CHECK_SM = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6.5L9 17.5l-5-5"/></svg>';
    var ICON_DOT = {
        done: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6.5L9 17.5l-5-5"/></svg>',
        busy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>',
        wait: ''
    };

    /* ------------------------------------------------------------------
     * DOM references
     * ----------------------------------------------------------------*/

    var dom = {};

    function cacheDom() {
        dom.panels        = document.querySelectorAll('.tamrank-wizard__panel');
        dom.steps         = document.querySelectorAll('.tamrank-wizard__step');
        dom.skipBtn       = document.getElementById('tamrank-skip');
        dom.detectResult  = document.getElementById('tamrank-detect-result');
        dom.detectDesc    = document.getElementById('tamrank-detect-desc');
        dom.detectNext    = document.getElementById('tamrank-detect-next');
        dom.detectTrust     = document.getElementById('tamrank-detect-trust');
        dom.detectTrustRead = document.getElementById('tamrank-detect-trust-read');
        dom.detectTrustKeep = document.getElementById('tamrank-detect-trust-keep');

        dom.planTitle     = document.getElementById('tamrank-plan-title');
        dom.planSubtitle  = document.getElementById('tamrank-plan-subtitle');
        dom.planFound     = document.getElementById('tamrank-plan-found');
        dom.planToggle    = document.getElementById('tamrank-plan-toggle');
        dom.planTotalRow  = document.getElementById('tamrank-plan-total-row');
        dom.planTotal     = document.getElementById('tamrank-plan-total');
        dom.categories    = document.getElementById('tamrank-categories');

        dom.factsDefault  = document.getElementById('tamrank-facts-default');
        dom.factsLoading  = document.getElementById('tamrank-facts-loading');
        dom.factsFailed   = document.getElementById('tamrank-facts-failed');
        dom.factsEmpty    = document.getElementById('tamrank-facts-empty');
        dom.factsList     = document.getElementById('tamrank-facts-list');
        dom.scanRetry     = document.getElementById('tamrank-scan-retry');
        dom.scanTotal     = document.getElementById('tamrank-scan-total');
        dom.scanDesc      = document.getElementById('tamrank-scan-desc');
        dom.scanDup       = document.getElementById('tamrank-scan-dup');
        dom.scanSchema    = document.getElementById('tamrank-scan-schema');
        dom.scanOpen      = document.getElementById('tamrank-scan-open');
        dom.scanWarning   = document.getElementById('tamrank-scan-warning');
        dom.scanWarningText = document.getElementById('tamrank-scan-warning-text');

        dom.robotsPanel   = document.getElementById('tamrank-robots-panel');
        dom.redirectsNote = document.getElementById('tamrank-redirects-note');
        dom.redirectsNoteText = document.getElementById('tamrank-redirects-note-text');
        dom.externalRedirectsNote = document.getElementById('tamrank-external-redirects-note');
        dom.externalRedirectsNoteText = document.getElementById('tamrank-external-redirects-note-text');
        dom.robotsPhysical= document.getElementById('tamrank-robots-physical');
        dom.robotsOrigin  = document.getElementById('tamrank-robots-origin');
        dom.robotsConflict= document.getElementById('tamrank-robots-conflict');
        dom.robotsConflictCap = document.getElementById('tamrank-robots-conflict-cap');
        dom.robotsCurrent = document.getElementById('tamrank-robots-current');
        dom.robotsNew     = document.getElementById('tamrank-robots-new');

        dom.safetyMessage = document.getElementById('tamrank-safety-message');
        dom.safetyText    = document.getElementById('tamrank-safety-text');
        dom.trustCopy     = document.getElementById('tamrank-trust-copy');
        dom.trustBackup   = document.getElementById('tamrank-trust-backup');
        dom.trustKeep     = document.getElementById('tamrank-trust-keep');

        dom.migrateStart      = document.getElementById('tamrank-migrate-start');
        dom.migrateStartLabel = document.getElementById('tamrank-migrate-start-label');
        dom.migrateSubtitle   = document.getElementById('tamrank-migrate-subtitle');
        dom.progressBar   = document.getElementById('tamrank-progress-bar');
        dom.progressFill  = document.getElementById('tamrank-progress-fill');
        dom.progressPct   = document.getElementById('tamrank-progress-pct');
        dom.progressItems = document.getElementById('tamrank-progress-items');
        dom.migrateList   = document.getElementById('tamrank-migrate-list');
        dom.migLog        = document.getElementById('tamrank-mig-log');
        dom.errorBox      = document.getElementById('tamrank-error-box');
        dom.errorMsg      = document.getElementById('tamrank-error-msg');
        dom.errorRetry    = document.getElementById('tamrank-error-retry');
        dom.errorSkip     = document.getElementById('tamrank-error-skip');
        dom.errorStop     = document.getElementById('tamrank-error-stop');

        dom.successStats  = document.getElementById('tamrank-success-stats');
        dom.successHead   = document.getElementById('tamrank-success-head');
        dom.successBlock  = document.getElementById('tamrank-success-block');
        dom.successTitle  = document.getElementById('tamrank-success-title');
        dom.successSub    = document.getElementById('tamrank-success-subtitle');
        dom.migrationReport = document.getElementById('tamrank-migration-report');
        dom.quickWin      = document.getElementById('tamrank-quick-win');
        dom.quickWinText  = document.getElementById('tamrank-quick-win-text');
        dom.zeroErrorsBadge = document.getElementById('tamrank-zero-errors');
        dom.confetti      = document.getElementById('tamrank-confetti');

        dom.rollbackBlock   = document.getElementById('tamrank-rollback-block');
        dom.rollbackBody    = document.getElementById('tamrank-rollback-body');
        dom.rollbackKeep    = document.getElementById('tamrank-rollback-keep');
        dom.rollbackActions = document.getElementById('tamrank-rollback-actions');
        dom.rollbackStart   = document.getElementById('tamrank-rollback-start');
        dom.rollbackConfirm = document.getElementById('tamrank-rollback-confirm');
        dom.rollbackYes     = document.getElementById('tamrank-rollback-yes');
        dom.rollbackCancel  = document.getElementById('tamrank-rollback-cancel');
        dom.rollbackStatus  = document.getElementById('tamrank-rollback-status');

        dom.langSwitcher  = document.getElementById('tamrank-lang-switcher');
        dom.stepper       = document.querySelector('.tamrank-wizard__stepper');
    }

    /**
     * Short helper for a localized string with an English source fallback.
     */
    function str(key, fallback) {
        var strings = cfg.strings || {};
        return strings[key] || fallback;
    }

    /**
     * Fill one printf-style placeholder in a copy string.
     *
     * Every placeholder in this file goes through here, and the reason is the
     * second argument of String.replace(): a STRING replacement is not
     * inserted literally. `$&`, `` $` ``, `$'`, `$1` and `$$` inside it are
     * expanded against the match, so a value carrying a dollar sign came out
     * mangled — a source named "A$&B" printed as "A%sB". A FUNCTION
     * replacement is exempt from that expansion, which is the whole trick
     * here. Only the replacement is affected; a `$` in the placeholder itself
     * (`%1$s`) has always been literal, because that is the search argument.
     *
     * Today's values are plugin names and formatted numbers, so nothing is
     * visibly broken. The point is that the next value to reach this file —
     * a file path, a server message, a post title — does not have to be
     * audited for dollar signs first.
     *
     * THIS IS THE CANONICAL COPY. Seven other admin scripts carry a byte-equal
     * copy of the body: schema-detector.js, content-tab.js, tamrank-tour.js,
     * meta-box.js, tamrank-webp-admin.js, tr-robots.js and
     * tamrank-schema-builder.js. A copy, and not a shared global, because those
     * seven are enqueued from seven different call sites across five screen
     * families, and the only handle that could carry a shared helper
     * (tam-rank-admin, which creates window.TamRank) is registered inside the
     * $is_tamrank_page guard in tam-rank.php and never registered outside it.
     * Naming it as a dependency there means WordPress looks the handle up in
     * the registry, and on a screen where that guard does not pass it finds
     * nothing and drops the DEPENDENT script entirely, without a word. That
     * trades a string bug for a dead screen. The function is four lines, pure
     * and stateless, so the cost of the copy is a grep away. Change the body
     * here and in the other seven together.
     *
     * @param {string} tpl          Copy carrying the placeholder.
     * @param {string|RegExp} token Placeholder to fill, e.g. '%s' or '%1$s'.
     *                              A RegExp works the same way: it is the
     *                              replacement, never the search, that expands.
     * @param {*} value             Replacement, inserted literally.
     * @return {string}
     */
    function fillPlaceholder(tpl, token, value) {
        var literal = String(value);
        return String(tpl).replace(token, function () { return literal; });
    }

    /* ------------------------------------------------------------------
     * Navigation
     * ----------------------------------------------------------------*/

    function goToStep(next) {
        if (state.isTransitioning || next === state.currentStep) return;
        if (next < 0 || next >= state.totalSteps) return;

        var direction = next > state.currentStep ? 'forward' : 'backward';
        var currentPanel = dom.panels[state.currentStep];
        var nextPanel = dom.panels[next];

        state.isTransitioning = true;

        // Slide out current
        if (prefersReducedMotion) {
            currentPanel.classList.remove('is-active');
            finishTransition(next, nextPanel, direction);
        } else {
            currentPanel.classList.add(direction === 'forward' ? 'is-sliding-out' : 'is-sliding-out-reverse');
            setTimeout(function () {
                currentPanel.classList.remove('is-active', 'is-sliding-out', 'is-sliding-out-reverse');
                finishTransition(next, nextPanel, direction);
            }, 300);
        }
    }

    function finishTransition(next, nextPanel, direction) {
        updateStepper(next);

        if (!prefersReducedMotion && direction === 'backward') {
            nextPanel.classList.add('is-sliding-in-reverse');
            setTimeout(function () {
                nextPanel.classList.remove('is-sliding-in-reverse');
            }, 300);
        }
        nextPanel.classList.add('is-active');

        state.currentStep = next;
        state.isTransitioning = false;

        // Focus management
        var title = nextPanel.querySelector('.tamrank-sheet__title');
        if (title) title.focus();

        onStepEnter(next);
    }

    function updateStepper(activeIndex) {
        // The stepper stays visible on every step, Done included: the whole
        // route in one glance is the one thing the old five-step wizards do
        // better than the modern ones.
        dom.steps.forEach(function (step, i) {
            step.classList.remove('is-active', 'is-completed');
            step.setAttribute('aria-current', 'false');

            if (i < activeIndex) {
                step.classList.add('is-completed');
            } else if (i === activeIndex) {
                step.classList.add('is-active');
                step.setAttribute('aria-current', 'step');
            }
        });
    }

    function onStepEnter(step) {
        switch (step) {
            case STEP_DETECT:
                runDetection();
                break;
            case STEP_PLAN:
                // Always re-fetch to pick up freshly added SEO data (cache is
                // bypassed server-side); the scan is what fills both the
                // category list and the side track of this step.
                syncPlanHeadings();
                runScan(function () {
                    // renderScanResults() already ran buildCategories() on the
                    // success path (and buildCategories() fires the robots
                    // preview, which is a server-side loopback capture). Doing
                    // it again here built the list twice and asked the server
                    // twice on every entry into this step. Only the failure
                    // path still needs an answer.
                    if (!state.scanData) renderCategoriesUnavailable();
                });
                break;
            case STEP_DONE:
                markComplete();
                playPeakMoment();
                break;
        }
    }

    /* ------------------------------------------------------------------
     * AJAX helpers
     * ----------------------------------------------------------------*/

    /**
     * @param {string}   action
     * @param {Object}   data
     * @param {Function} callback  (err, payload, success)
     * @param {number}   [timeoutMs]  Abort after this many ms and report a
     *                                timeout error. Opt-in per call: the
     *                                migration batches may legitimately run
     *                                long, so no global deadline is imposed.
     */
    function ajax(action, data, callback, timeoutMs) {
        var formData = new FormData();
        formData.append('action', 'tamrank_onboarding_' + action);
        formData.append('nonce', cfg.nonce);

        if (data) {
            Object.keys(data).forEach(function (key) {
                var val = data[key];
                if (Array.isArray(val)) {
                    val.forEach(function (v) {
                        formData.append(key + '[]', v);
                    });
                } else {
                    formData.append(key, val);
                }
            });
        }

        var xhr = new XMLHttpRequest();
        xhr.open('POST', cfg.ajaxUrl);
        if (timeoutMs) {
            xhr.timeout = timeoutMs;
            xhr.ontimeout = function () {
                callback(new Error('Timeout'));
            };
        }
        xhr.onload = function () {
            try {
                var resp = JSON.parse(xhr.responseText);
                callback(null, resp.data, resp.success);
            } catch (e) {
                callback(e);
            }
        };
        xhr.onerror = function () {
            callback(new Error('Network error'));
        };
        xhr.send(formData);
    }

    /* ------------------------------------------------------------------
     * Step 0: Detection
     * ----------------------------------------------------------------*/

    /**
     * @param {function} [done] Optional callback, always invoked once — also on
     *        error, so a caller that waits on detection is never stranded.
     */
    function runDetection(done) {
        if (state.detection) {
            renderDetection(state.detection);
            if (typeof done === 'function') done();
            return;
        }

        ajax('detect', null, function (err, data) {
            if (err || !data) {
                dom.detectResult.innerHTML = '<p style="color:var(--tr-color-error)">' + escHtml(str('error', 'Error')) + '</p>';
                if (typeof done === 'function') done();
                return;
            }
            state.detection = data;
            renderDetection(data);
            if (typeof done === 'function') done();
        });
    }

    function renderDetection(data) {
        var plugins = [
            { key: 'yoast',     name: 'Yoast SEO',      icon: 'Y', colorVar: '#a4286a' },
            { key: 'rankmath',  name: 'Rank Math',      icon: 'R', colorVar: '#e74262' },
            { key: 'aioseo',    name: 'All in One SEO', icon: 'A', colorVar: '#005ae0' }
        ];
        var sources = data.sources || (data.plugin ? [data] : []);

        // Pre-select here, NOT in the click handler: the default happy path
        // never clicks the pre-checked row, so waiting for a click would
        // dead-end every migration on the start guard. A still-valid earlier
        // choice is PRESERVED across re-renders (Back navigation re-enters
        // this function with cached detection data) — resetting to the first
        // source while keeping the cached scan would migrate a different
        // source than the one previewed. When the effective source does
        // change, the cached scan is stale and must be dropped.
        var prevFlow   = state.chosenFlow;
        var prevSource = state.selectedSource;
        var prevValid  = false;
        sources.forEach(function (s) { if (s.plugin === prevSource) prevValid = true; });

        if (!sources.length) {
            state.chosenFlow = 'clean';
            state.selectedSource = null;
        } else if (prevFlow === 'clean') {
            state.selectedSource = null;
        } else {
            state.chosenFlow = 'migrate';
            state.selectedSource = prevValid ? prevSource : sources[0].plugin;
        }
        if (state.selectedSource !== prevSource) {
            state.scanData = null;
            state.scanFailed = false;
        }

        var html = '<div class="tamrank-src tamrank-rise" role="radiogroup" aria-label="' +
                   escHtml(str('wizard_plugin_list_aria', 'Choose how to start')) + '">';

        // One row per detected source (the effective choice pre-selected)
        sources.forEach(function (src) {
            var meta = null;
            plugins.forEach(function (p) {
                if (p.key === src.plugin) meta = p;
            });
            if (!meta) return;
            var isSelected = state.chosenFlow === 'migrate' && src.plugin === state.selectedSource;
            var dataCount = src.data_count || 0;
            var label = (dataCount === 1)
                ? str('item_with_seo_data', 'item with SEO data')
                : str('items_with_seo_data', 'items with SEO data');
            html += '<button type="button" class="tamrank-src__row' + (isSelected ? ' is-selected' : '') + '" role="radio" aria-checked="' + (isSelected ? 'true' : 'false') + '" data-flow="migrate" data-plugin="' + escHtml(meta.key) + '">';
            html += '<span class="tamrank-src__radio" aria-hidden="true"></span>';
            html += '<span class="tamrank-src__icon" style="background:' + meta.colorVar + '">' + meta.icon + '</span>';
            html += '<span><span class="tamrank-src__name">' + escHtml(meta.name) + '</span>';
            html += '<span class="tamrank-src__meta">' + dataCount + ' ' + escHtml(label) + '</span></span>';
            html += '<span class="tamrank-src__badge">' + escHtml(str('detected', 'Detected')) + '</span>';
            html += '</button>';
        });

        // Clean start row (always present — selected when the clean flow is
        // the effective choice: nothing detected, or user chose it earlier)
        var cleanSelected = state.chosenFlow === 'clean';
        html += '<button type="button" class="tamrank-src__row' + (cleanSelected ? ' is-selected' : '') + '" role="radio" aria-checked="' + (cleanSelected ? 'true' : 'false') + '" data-flow="clean" data-plugin="clean">';
        html += '<span class="tamrank-src__radio" aria-hidden="true"></span>';
        html += '<span class="tamrank-src__icon tamrank-src__icon--clean" aria-hidden="true">';
        html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>';
        html += '</span>';
        html += '<span><span class="tamrank-src__name">' + escHtml(str('wizard_clean_start', 'Clean start')) + '</span>';
        html += '<span class="tamrank-src__meta">' + escHtml(str('wizard_clean_start_desc', 'Start fresh without migration')) + '</span></span>';
        html += '</button>';

        html += '</div>';

        dom.detectResult.innerHTML = html;

        // The reassurance line lives in the document foot, next to the button.
        if (dom.detectDesc) {
            if (sources.length > 1) {
                var multiTpl = str('multiSourceDesc',
                    'We found SEO data from %d plugins. Choose which one you want to import. The rest stays untouched.');
                dom.detectDesc.textContent = fillPlaceholder(multiTpl, '%d', sources.length);
            } else if (sources.length === 1) {
                dom.detectDesc.textContent = str('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.');
            } else {
                dom.detectDesc.textContent = str('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.');
            }
        }

        dom.detectNext.disabled = false;
        syncDetectTrust();

        // Row selection handler
        var list = dom.detectResult.querySelector('.tamrank-src');
        if (list) {
            list.addEventListener('click', function (e) {
                var row = e.target.closest('.tamrank-src__row');
                if (!row) return;
                list.querySelectorAll('.tamrank-src__row').forEach(function (r) {
                    r.classList.remove('is-selected');
                    r.setAttribute('aria-checked', 'false');
                });
                row.classList.add('is-selected');
                row.setAttribute('aria-checked', 'true');
                state.chosenFlow = row.dataset.flow || 'migrate';
                var newSource = row.dataset.flow === 'migrate' ? (row.dataset.plugin || null) : null;
                if (newSource !== state.selectedSource) {
                    // Source switch invalidates the cached scan — otherwise
                    // runScan()'s early-return would serve source A's
                    // categories under source B.
                    state.scanData = null;
                    state.scanFailed = false;
                }
                state.selectedSource = newSource;
                // The line names the chosen plugin, so it has to follow the
                // choice - not only the first render.
                syncDetectTrust();
            });
        }
    }

    /* ------------------------------------------------------------------
     * Trust layer (TRUST-01)
     *
     * Every line names the detected plugin where we know it, and falls back
     * to a generic wording where we do not. The fallback is not cosmetic: on
     * the clean-start flow and on a detection without a plugin_name there is
     * no name to print, and a dangling "%s" would be worse than no name.
     * ----------------------------------------------------------------*/

    /**
     * Name of the source the user has selected, or '' when there is none.
     * @return {string}
     */
    function selectedPluginName() {
        var src = getSelectedSource();
        return (state.chosenFlow === 'migrate' && src && src.plugin_name) ? src.plugin_name : '';
    }

    /**
     * Pick the named or the generic variant of a string pair and fill in the
     * plugin name. Both fallbacks are English source copy, mirroring the rest
     * of this file: the localized values arrive through cfg.strings.
     *
     * @param {string} key         Key of the "%s" variant.
     * @param {string} genericKey  Key of the variant without a name.
     * @param {string} fallback    English source of the "%s" variant.
     * @param {string} genericFb   English source of the generic variant.
     * @param {string} name        Plugin name, '' for the generic variant.
     * @return {string}
     */
    function trustCopy(key, genericKey, fallback, genericFb, name) {
        if (!name) return str(genericKey, genericFb);
        return fillPlaceholder(str(key, fallback), '%s', name);
    }

    /**
     * Step 0: the promise at the first point of contact, under the source
     * rows. Hidden on the clean-start flow, where TamRank reads no other
     * plugin's data at all.
     */
    function syncDetectTrust() {
        if (!dom.detectTrust) return;
        var name = selectedPluginName();
        if (state.chosenFlow !== 'migrate') {
            dom.detectTrust.hidden = true;
            if (dom.detectTrustRead) dom.detectTrustRead.textContent = '';
            if (dom.detectTrustKeep) dom.detectTrustKeep.textContent = '';
            return;
        }
        if (dom.detectTrustRead) {
            dom.detectTrustRead.textContent = trustCopy(
                'wizard_detect_trust',
                'wizard_detect_trust_generic',
                'TamRank reads your %s data. It never changes or deletes it.',
                'TamRank reads your existing SEO data. It never changes or deletes it.',
                name
            );
        }
        if (dom.detectTrustKeep) {
            dom.detectTrustKeep.textContent = trustCopy(
                'wizard_detect_trust_keep',
                'wizard_detect_trust_keep_generic',
                'You do not have to remove %s. Keep it active until you are happy with the result.',
                'You do not have to remove your old SEO plugin. Keep it active until you are happy with the result.',
                name
            );
        }
        dom.detectTrust.hidden = false;
    }

    /**
     * Step 1: the mechanism strip above the start button. Three checkable
     * facts, in the order the user meets them: we copy, we back up first,
     * and the old plugin may stay on.
     */
    function fillTrustCard() {
        var name = selectedPluginName();

        if (dom.safetyText) {
            dom.safetyText.textContent = trustCopy(
                'wizard_trust_title',
                'wizard_trust_title_generic',
                'You can switch back to %s at any time.',
                'You can switch back at any time.',
                name
            );
        }
        if (dom.trustCopy) {
            dom.trustCopy.textContent = trustCopy(
                'wizard_trust_copy',
                'wizard_trust_copy_generic',
                'We copy your %s data. We never move or delete anything.',
                'We copy your existing SEO data. We never move or delete anything.',
                name
            );
        }
        if (dom.trustBackup) {
            // No plugin name in this one: the backup is ours, not theirs.
            dom.trustBackup.textContent = str('wizard_trust_backup',
                'Before anything changes we save a backup. The backup is kept until you use it.');
        }
        if (dom.trustKeep) {
            dom.trustKeep.textContent = trustCopy(
                'wizard_trust_keep',
                'wizard_trust_keep_generic',
                'Keep %s active until you are happy with the result. Turning it off can wait.',
                'Keep your old SEO plugin active until you are happy with the result. Turning it off can wait.',
                name
            );
        }
    }

    /**
     * Step 1 headings: the step IS the question, so the title names the source.
     */
    function syncPlanHeadings() {
        var name = selectedPluginName();
        if (dom.planTitle) {
            dom.planTitle.textContent = trustCopy(
                'wizard_plan_title',
                'wizard_plan_title_generic',
                'What do we take along from %s?',
                'What do we take along?',
                name
            );
        }
        if (dom.planSubtitle) {
            dom.planSubtitle.textContent = trustCopy(
                'wizard_plan_subtitle',
                'wizard_plan_subtitle_generic',
                'Tick what TamRank moves over. Your %s data stays where it is.',
                'Tick what TamRank moves over. Your existing SEO data stays where it is.',
                name
            );
        }
        if (dom.planFound) {
            dom.planFound.textContent = trustCopy(
                'wizard_plan_found',
                'wizard_plan_found_generic',
                'Found in %s',
                'Found in your old SEO plugin',
                name
            );
        }
    }

    /**
     * The detection entry of the source the user chose. Falls back to the
     * legacy first-match detection shape when no explicit choice applies.
     */
    function getSelectedSource() {
        var det = state.detection;
        if (!det) return null;
        var sources = det.sources || [];
        for (var i = 0; i < sources.length; i++) {
            if (sources[i].plugin === state.selectedSource) return sources[i];
        }
        return det.plugin ? det : null;
    }

    /* ------------------------------------------------------------------
     * Step 1: the side track (SEO scan)
     * ----------------------------------------------------------------*/

    /**
     * Abort a scan that has not answered after this long and fall into the
     * failure state. Chosen at 20s because a cold scan is a handful of indexed
     * COUNT queries — sub-second on a normal site, a few seconds on a very
     * large one — while most hosts cut PHP off at max_execution_time = 30s.
     * Past ~20s the request is either stuck or about to be killed server-side
     * anyway, so waiting longer only prolongs a pulsing skeleton.
     */
    var SCAN_TIMEOUT_MS = 20000;

    function setScanState(name) {
        if (dom.factsLoading) dom.factsLoading.hidden = name !== 'loading';
        if (dom.factsFailed)  dom.factsFailed.hidden  = name !== 'failed';
        if (dom.factsEmpty)   dom.factsEmpty.hidden   = name !== 'empty';
        if (dom.factsList)    dom.factsList.hidden    = name !== 'results';

        // Anything hanging off the results (the open-work line, the large-site
        // notice) belongs to the results state only.
        if (name !== 'results') {
            if (dom.scanOpen) dom.scanOpen.hidden = true;
            if (dom.scanWarning) dom.scanWarning.hidden = true;
            // The left-behind-redirects note is not part of the facts block, but
            // it does need a scan to know its number: hide it until one lands.
            // renderScanResults() calls renderRedirectsNote() right after this,
            // so the empty-results state still gets its note back.
            if (dom.redirectsNote) dom.redirectsNote.hidden = true;
            if (dom.externalRedirectsNote) dom.externalRedirectsNote.hidden = true;
        }
    }

    function runScan(done) {
        if (state.scanData) {
            renderScanResults(state.scanData);
            if (typeof done === 'function') done(null, state.scanData);
            return;
        }

        state.scanFailed = false;
        setScanState('loading');
        renderCategoriesLoading();

        ajax('scan', state.selectedSource ? { source: state.selectedSource } : null, function (err, data, ok) {
            // A wp_send_json_error payload ({message, code}) is truthy, so the
            // success flag has to be checked too — otherwise the error object
            // gets cached as scan data (all-zero stats, empty categories).
            if (err || ok === false || !data) {
                state.scanFailed = true;
                setScanState('failed');
                if (typeof done === 'function') done(err || new Error('scan failed'));
                return;
            }
            state.scanData = data;
            renderScanResults(data);
            if (typeof done === 'function') done(null, data);
        }, SCAN_TIMEOUT_MS);
    }

    function retryScan() {
        state.scanData = null;
        runScan(function () {
            // Same as onStepEnter(STEP_PLAN): the success path is already
            // rendered by renderScanResults().
            if (!state.scanData) renderCategoriesUnavailable();
        });
    }

    /**
     * Format an integer in the wizard's own language, not the browser's.
     */
    function fmtNumber(n) {
        var locales = { nl: 'nl-NL', de: 'de-DE', fr: 'fr-FR', en: 'en-US' };
        try {
            return Number(n).toLocaleString(locales[state.languageSelected] || 'en-US');
        } catch (e) {
            return String(n);
        }
    }

    /**
     * "336 seconds" is not a duration a human reads. Same arithmetic as the
     * migration estimate (50 items per 2s batch), rounded to minutes once it
     * stops being a short wait.
     */
    function fmtDuration(items) {
        var sec = Math.ceil(items / 50 * 2);
        if (sec < 90) {
            return fillPlaceholder(str('wizard_scan_duration_seconds', 'about %s seconds'), '%s', fmtNumber(sec));
        }
        return fillPlaceholder(str('wizard_scan_duration_minutes', 'about %s minutes'), '%s', fmtNumber(Math.round(sec / 60)));
    }

    /**
     * Write one figure. The value lives in its own .tamrank-fact__val span so
     * the "%" unit next to it keeps its own (smaller) size, and so a zero can
     * become the word "none" without touching the unit.
     */
    function setFigure(wrap, value, zeroWord) {
        if (!wrap) return;
        var val = wrap.querySelector('.tamrank-fact__val') || wrap;
        var isZero = value === 0 && zeroWord;
        val.textContent = isZero ? str('wizard_scan_value_none', 'none') : fmtNumber(value);
        wrap.classList.toggle('tamrank-fact__num--muted', !!isZero);
    }

    function renderScanResults(data) {
        var total   = data.total_posts || 0;
        var desc    = data.missing_desc || 0;
        var dup     = data.duplicate_titles || 0;
        var schema  = data.schema_pct || 0;

        // An empty site gets one honest line instead of four zeroes.
        if (total === 0) {
            setScanState('empty');
        } else {
            setScanState('results');

            setFigure(dom.scanTotal, total, false);
            setFigure(dom.scanDesc, desc, true);
            setFigure(dom.scanDup, dup, true);
            if (dom.scanSchema) dom.scanSchema.textContent = fmtNumber(schema);

            var openCount = (desc > 0 ? 1 : 0) + (dup > 0 ? 1 : 0);
            if (dom.scanOpen) dom.scanOpen.hidden = openCount === 0;

            if (dom.scanWarning) {
                var big = total > 5000;
                dom.scanWarning.hidden = !big;
                if (big && dom.scanWarningText) {
                    dom.scanWarningText.textContent = fillPlaceholder(
                        fillPlaceholder(
                            str('large_site_warning', '%1$s items means the migration takes %2$s. You can leave this tab open.'),
                            '%1$s', fmtNumber(total)
                        ),
                        '%2$s', fmtDuration(total)
                    );
                }
            }
        }

        renderRedirectsNote(data);
        renderExternalRedirectsNote(data);
        buildCategories(data);
        fillTrustCard();
    }

    /**
     * Redirects the source has that this migration does not bring along.
     *
     * Rendered outside the scan-facts block on purpose: it is not "what else we
     * saw on your site", it is a gap in the moving plan, and it has to be
     * readable whether or not the scan produced figures. AIOSEO Pro is the case
     * that exists today: its redirects live in an add-on table with no
     * TamRank counterpart, so the wizard offers no redirects category at all -
     * and used to say nothing whatsoever.
     *
     * @param {Object} data Scan payload.
     */
    function renderRedirectsNote(data) {
        if (!dom.redirectsNote) return;

        var n = (data && data.unmigratable_redirects) || 0;
        dom.redirectsNote.hidden = n <= 0;
        if (n <= 0 || !dom.redirectsNoteText) return;

        var name = selectedPluginName();
        var tpl = (n === 1)
            ? trustCopy('wizard_redirects_left_one', 'wizard_redirects_left_one_generic',
                '1 redirect found in %s. TamRank does not take redirects over from it, so that one stays where it is.',
                '1 redirect found in your old SEO plugin. TamRank does not take redirects over from it, so that one stays where it is.',
                name)
            : trustCopy('wizard_redirects_left', 'wizard_redirects_left_generic',
                '%d redirects found in %s. TamRank does not take redirects over from it, so they stay where they are.',
                '%d redirects found in your old SEO plugin. TamRank does not take redirects over from it, so they stay where they are.',
                name);

        dom.redirectsNoteText.textContent = fillPlaceholder(tpl, '%d', fmtNumber(n));
    }

    /**
     * Redirects this migration DOES import that point at another domain.
     *
     * The import performs them exactly as the old plugin did - an external
     * target is a supported rule, not an accident. What the plan could not show
     * until now is that part of the traffic it is about to take over leaves the
     * site altogether. So it is counted and said, and nothing else changes:
     * nothing is blocked, nothing is filtered out of the import.
     *
     * The number comes from the same set the Redirects category counts, so it
     * can never be larger than the figure on the row above it.
     *
     * @param {Object} data Scan payload.
     */
    function renderExternalRedirectsNote(data) {
        if (!dom.externalRedirectsNote) return;

        var n = (data && data.external_redirect_targets) || 0;
        dom.externalRedirectsNote.hidden = n <= 0;
        if (n <= 0 || !dom.externalRedirectsNoteText) return;

        var tpl = (n === 1)
            ? str('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.')
            : str('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.');

        dom.externalRedirectsNoteText.textContent = fillPlaceholder(tpl, '%d', fmtNumber(n));
    }

    /* ------------------------------------------------------------------
     * Step 1: the moving plan (categories)
     * ----------------------------------------------------------------*/

    function renderCategoriesLoading() {
        if (!dom.categories) return;
        var html = '';
        for (var i = 0; i < 4; i++) {
            html += '<div class="tamrank-plan__row" aria-hidden="true">' +
                    '<span class="tamrank-plan__box"></span>' +
                    '<span class="tamrank-plan__body"><span class="tamrank-skel" style="width:' + (52 + i * 9) + '%"></span></span>' +
                    '<span class="tamrank-plan__count"><span class="tamrank-skel tamrank-skel--num"></span></span>' +
                    '</div>';
        }
        dom.categories.innerHTML = html;
        fitCategoryList();
        state.selectedCategories = [];
        if (dom.planTotal) dom.planTotal.innerHTML = '&nbsp;';
        if (dom.planToggle) dom.planToggle.hidden = true;
        if (dom.migrateStart) dom.migrateStart.disabled = true;
    }

    /**
     * Nothing to select: either the scan came back empty, or it failed and we
     * have no list at all. Both keep the flow open — the primary button turns
     * into "continue without migration".
     */
    function renderCategoriesUnavailable() {
        if (!dom.categories) return;
        var src = getSelectedSource();
        var hasPlugin = src && src.plugin;
        var msg;
        if (state.scanFailed) {
            msg = str('wizard_scan_failed_text',
                'We could not scan your site just now. That may be a temporary glitch; try again, or simply continue.');
        } else {
            msg = hasPlugin
                ? str('wizard_no_seo_data', 'No existing SEO data found to migrate. You can start with a clean slate.')
                : str('wizard_no_migration_needed', 'Nothing to migrate. TamRank will start fresh.');
        }

        dom.categories.innerHTML =
            '<div class="tamrank-plan__empty">' +
            '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>' +
            '<span>' + escHtml(msg) + '</span>' +
            '</div>';

        fitCategoryList();
        state.selectedCategories = [];
        if (dom.planToggle) dom.planToggle.hidden = true;
        if (dom.planTotalRow) dom.planTotalRow.hidden = true;
        if (dom.migrateStart) {
            dom.migrateStart.disabled = false;
            if (dom.migrateStartLabel) {
                dom.migrateStartLabel.textContent = str('wizard_skip_migration', 'Continue without migration');
            }
        }

        // Nothing to protect when nothing migrates.
        if (dom.safetyMessage) dom.safetyMessage.hidden = true;
        if (dom.robotsPanel) dom.robotsPanel.hidden = true;
        if (dom.robotsConflict) dom.robotsConflict.hidden = true;
        if (dom.robotsPhysical) dom.robotsPhysical.hidden = true;
        if (dom.factsDefault) dom.factsDefault.hidden = false;
    }

    function buildCategories(scanData) {
        var cats = (scanData && scanData.categories) ? scanData.categories : [];
        var src = getSelectedSource();
        if (!src || !src.plugin || !cats.length) {
            renderCategoriesUnavailable();
            return;
        }

        var html = '';
        state.selectedCategories = [];

        cats.forEach(function (cat) {
            state.selectedCategories.push(cat.key);
            var whatKey = CATEGORY_WHAT_KEYS[cat.key];
            var what = whatKey ? str(whatKey, '') : '';
            html += '<label class="tamrank-plan__row is-checked" data-cat="' + escHtml(cat.key) + '" data-count="' + (cat.count || 0) + '">';
            html += '<input type="checkbox" checked>';
            html += '<span class="tamrank-plan__box" aria-hidden="true">' + ICON_CHECK_SM + '</span>';
            html += '<span class="tamrank-plan__body"><span class="tamrank-plan__name">' + escHtml(cat.label) + '</span>';
            if (what) {
                html += '<span class="tamrank-plan__what">' + escHtml(what) + '</span>';
            }
            html += '</span>';
            html += '<span class="tamrank-plan__count">' + (cat.count || 0) + '</span>';
            html += '</label>';
        });

        // Only the contents are replaced; the container itself (and with it the
        // delegated toggle handler bound once in bindEvents) stays alive.
        dom.categories.innerHTML = html;

        if (dom.planToggle) dom.planToggle.hidden = false;
        if (dom.planTotalRow) dom.planTotalRow.hidden = false;

        // Bring the mechanism strip back. renderCategoriesUnavailable() hides
        // it, and a user who lands on an empty selection first and then picks a
        // source with data would otherwise reach the start button with no trust
        // layer at all.
        if (dom.safetyMessage) dom.safetyMessage.hidden = false;

        syncPlan();
        fitCategoryList();

        // The robots.txt panel needs a live loopback capture, which is why it is a
        // separate request instead of part of the scan payload.
        var hasRobots = cats.some(function (c) { return c.key === 'robots_txt'; });
        if (hasRobots) {
            loadRobotsPreview(src.plugin);
        } else {
            resetRobotsPanel();
        }
    }

    /**
     * Snap the category list down to whole rows.
     *
     * The list is allowed to scroll inside itself (see #tamrank-categories in
     * the stylesheet), but 40vh lands wherever it lands, so the bottom row was
     * cut through the middle. With an overlay scrollbar, which is what macOS
     * gives you, that half row is the only sign the list continues at all, and
     * it reads as broken rather than as "scroll me".
     *
     * So the box is trimmed to the last row that fits whole. Measured, not
     * calculated from a constant: rows grow a line taller when a label wraps,
     * which happens in German and French before it happens in Dutch, and the
     * compact breakpoint takes padding off every row.
     */
    function fitCategoryList() {
        var el = dom.categories;
        if (!el) return;

        // Back to the stylesheet's own budget first, otherwise a resize would
        // keep trimming against the height the previous run wrote.
        el.style.maxHeight = '';

        var rows = el.querySelectorAll('.tamrank-plan__row[data-cat]');
        if (!rows.length) {
            updateCategoryFade();
            return;
        }

        var budget = parseFloat(window.getComputedStyle(el).maxHeight);
        if (!budget || !isFinite(budget)) {
            updateCategoryFade();
            return;
        }

        el.scrollTop = 0;
        var top = el.getBoundingClientRect().top;
        var fit = 0;

        for (var i = 0; i < rows.length; i++) {
            var bottom = rows[i].getBoundingClientRect().bottom - top;
            if (bottom > budget + 0.5) break;
            fit = bottom;
        }

        // fit stays 0 when the panel is not laid out yet (every rect is zero)
        // or when a single row is taller than the whole budget. Both are cases
        // where trimming would hide the list instead of tidying it, so the
        // stylesheet keeps the last word.
        if (fit > 0) {
            el.style.maxHeight = Math.round(fit) + 'px';
        }

        updateCategoryFade();
    }

    /**
     * The fade is the "this goes on" signal, so it is on exactly while there is
     * something below the fold and off the moment you reach the end.
     */
    function updateCategoryFade() {
        var el = dom.categories;
        if (!el) return;

        var more = el.scrollHeight - el.clientHeight - el.scrollTop > 1;
        el.classList.toggle('has-more', more);
    }

    /**
     * Running total plus the button that says what it is waiting for.
     * Les 1 uit de concurrentievergelijking: the scope sits next to the button.
     */
    function syncPlan() {
        var rows = dom.categories ? dom.categories.querySelectorAll('.tamrank-plan__row[data-cat]') : [];
        var total = 0;
        var on = 0;
        Array.prototype.forEach.call(rows, function (r) {
            if (r.classList.contains('is-checked')) {
                total += parseInt(r.dataset.count, 10) || 0;
                on++;
            }
        });

        if (dom.planTotal) {
            var tpl = (total === 1)
                ? str('wizard_plan_total_field', '%s field')
                : str('wizard_plan_total_fields', '%s fields');
            dom.planTotal.textContent = fillPlaceholder(tpl, '%s', fmtNumber(total));
        }
        if (dom.migrateStart) {
            dom.migrateStart.disabled = on === 0;
        }
        if (dom.migrateStartLabel) {
            dom.migrateStartLabel.textContent = on === 0
                ? str('wizard_plan_start_disabled', 'Pick what you want to take along first')
                : str('wizard_start_migration', 'Start migration');
        }
        if (dom.planToggle) {
            dom.planToggle.textContent = on === 0
                ? str('wizard_plan_select_all', 'Select everything')
                : str('wizard_plan_select_none', 'Select nothing');
        }
    }

    /* ------------------------------------------------------------------
     * Step 1: robots.txt preview, physical-file warning and conflict choice
     * ----------------------------------------------------------------*/

    function resetRobotsPanel() {
        if (dom.robotsPanel) dom.robotsPanel.hidden = true;
        if (dom.robotsConflict) dom.robotsConflict.hidden = true;
        if (dom.robotsPhysical) dom.robotsPhysical.hidden = true;
        if (dom.factsDefault) dom.factsDefault.hidden = false;
    }

    function loadRobotsPreview(plugin) {
        ajax('settings_preview', { source: plugin }, function (err, data, success) {
            if (err || !success || !data || !data.robots) {
                // A failed preview must never block the migration: the wizard simply
                // shows no panel and the import keeps its safe default (keep).
                resetRobotsPanel();
                return;
            }
            renderRobotsPreview(data.robots);
        }, 20000);
    }

    function renderRobotsPreview(robots) {
        var notes = robots.notes || [];

        // A physical robots.txt beats everything TamRank sets, so this one is
        // shown in both aside states, never swapped away by the conflict.
        if (dom.robotsPhysical) {
            if (robots.physical_file) {
                dom.robotsPhysical.innerHTML =
                    '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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 9v4M12 17h.01"/></svg><span></span>';
                dom.robotsPhysical.querySelector('span').textContent =
                    fillPlaceholder(str('robots_physical_file', ''), '%s', robots.physical_file);
                dom.robotsPhysical.hidden = false;
            } else {
                dom.robotsPhysical.hidden = true;
            }
        }

        var originMsg = '';
        if (robots.origin === 'loopback') {
            originMsg = str('robots_origin_live', '');
        } else if (notes.indexOf('capture_not_representative_nonpublic') > -1) {
            originMsg = str('robots_origin_nonpublic', '');
        } else if (notes.indexOf('capture_not_representative_rankmath') > -1) {
            originMsg = str('robots_origin_rankmath', '');
        } else if (robots.origin === 'options') {
            originMsg = fillPlaceholder(str('robots_origin_options', ''), '%s', robots.fail_reason || '');
        }
        if (robots.sitemap_rewritten && str('robots_sitemap_rewritten', '')) {
            originMsg += ' ' + str('robots_sitemap_rewritten', '');
        }
        if (notes.indexOf('aioseo_search_appearance_rules') > -1 && robots.search_appearance) {
            originMsg += ' ' + str('robots_search_appearance', '') + ' ' + robots.search_appearance.replace(/\n/g, ' / ');
        }

        // Only ask when there is something to decide: a stored value that is the
        // seeded default (or empty) is safe to overwrite and needs no question.
        var hasConflict = !robots.is_untouched && !!robots.target_content;

        if (hasConflict) {
            // The radios are static template elements, so without this reset a
            // "Replace" picked for source A stays picked when the user goes back
            // and chooses source B - and B's robots.txt would then be overwritten
            // with a decision that was never made about it. Every fresh preview
            // starts from the safe answer.
            var keepRadio = dom.robotsConflict.querySelector('input[name="tamrank_robots_conflict"][value="keep"]');
            if (keepRadio) keepRadio.checked = true;

            dom.robotsCurrent.textContent = robots.current_content || '';
            dom.robotsNew.textContent = robots.target_content || '';
            if (dom.robotsConflictCap) dom.robotsConflictCap.textContent = originMsg;
            dom.robotsConflict.hidden = false;
            // A decision beats context: the choice takes the side track's place.
            if (dom.factsDefault) dom.factsDefault.hidden = true;
        } else {
            dom.robotsConflict.hidden = true;
            if (dom.factsDefault) dom.factsDefault.hidden = false;
            if (dom.robotsOrigin) dom.robotsOrigin.textContent = originMsg;
            if (dom.robotsPanel) dom.robotsPanel.hidden = !originMsg;
        }
    }

    function getRobotsConflictChoice() {
        if (!dom.robotsConflict || dom.robotsConflict.hidden) return 'keep';
        var checked = dom.robotsConflict.querySelector('input[name="tamrank_robots_conflict"]:checked');
        return (checked && checked.value === 'replace') ? 'replace' : 'keep';
    }

    /* ------------------------------------------------------------------
     * Step 2: Migration
     * ----------------------------------------------------------------*/

    function startMigration() {
        if (!state.selectedCategories.length) return;
        if (state.chosenFlow === 'migrate' && !state.selectedSource) {
            // Unreachable in practice (selectedSource is initialised in
            // renderDetection), but never start without an explicit source.
            showMigrationError(str('error', 'An error occurred'));
            return;
        }

        state.migrationOffset = 0;
        state.migrationDone = 0;
        state.migrationErrors = [];
        state.migrationSkipped = 0;
        state.migrationWritten = 0;
        state.migrationSkippedItems = 0;
        state.migrationStopped = false;
        state.migrationNotes = [];
        state.migrationBatches = 0;
        state.migrationStartTime = Date.now();

        renderMigrateList('wait');
        if (dom.migLog) dom.migLog.innerHTML = '';
        updateProgress('running');

        goToStep(STEP_MIGRATE);
        runMigrationBatch();
    }

    /**
     * The moving list. Statuses are derived from what the batch API actually
     * reports: the site-wide categories finish on the first batch, the
     * post-based ones are all in flight until the run reports done. No
     * per-category claim is made that the server has not answered for.
     *
     * @param {string} phase 'wait' | 'running' | 'done'
     */
    function renderMigrateList(phase) {
        if (!dom.migrateList) return;
        var html = '';
        if (state.scanData && state.scanData.categories) {
            state.scanData.categories.forEach(function (cat) {
                if (state.selectedCategories.indexOf(cat.key) === -1) return;
                var status = 'wait';
                if (phase === 'done') {
                    status = 'done';
                } else if (phase === 'running') {
                    status = SITEWIDE_CATEGORIES.indexOf(cat.key) > -1 ? 'done' : 'busy';
                }
                html += '<li class="tamrank-mig__item is-' + status + '" data-cat="' + escHtml(cat.key) + '">';
                html += '<span class="tamrank-mig__dot" aria-hidden="true">' + ICON_DOT[status] + '</span>';
                html += '<span class="tamrank-mig__name">' + escHtml(cat.label) + '</span>';
                html += '<span class="tamrank-mig__count">' + cat.count + '</span>';
                html += '</li>';
            });
        }
        dom.migrateList.innerHTML = html;
    }

    /**
     * One log line. Les 5 uit de concurrentievergelijking: a bar without lines
     * leaves you wondering whether it hangs. Every line here is something the
     * server reported, never a guess.
     */
    function migLog(text, isFail) {
        if (!dom.migLog || !text) return;
        var li = document.createElement('li');
        li.textContent = text;
        if (isFail) li.className = 'is-fail';
        dom.migLog.appendChild(li);
        dom.migLog.scrollTop = dom.migLog.scrollHeight;
    }

    function runMigrationBatch() {
        dom.errorBox.hidden = true;

        var isFirstBatch = state.migrationBatches === 0;

        var payload = {
            categories: state.selectedCategories,
            offset: state.migrationOffset,
            robots_conflict: getRobotsConflictChoice()
        };
        // Only send source when explicitly chosen — appending null would
        // arrive server-side as the string "null" and fail validation.
        if (state.selectedSource) {
            payload.source = state.selectedSource;
        }

        ajax('migrate', payload, function (err, data, success) {
            if (err || !success) {
                // Surface the server's own message (e.g. the fail-closed
                // source-validation errors) instead of the generic fallback.
                var msg = err ? err.message : ((data && data.message) || str('error', 'Error'));
                showMigrationError(msg);
                migLog(msg, true);
                return;
            }

            state.migrationBatches++;
            state.migrationDone += (data.processed || 0);
            state.migrationWritten += (data.written || 0);
            state.migrationSkippedItems += (data.skipped_items || 0);
            state.migrationOffset = data.next_offset || 0;

            if (data.errors && data.errors.length) {
                state.migrationErrors = state.migrationErrors.concat(data.errors);
            }

            if (data.notes && data.notes.length) {
                state.migrationNotes = (state.migrationNotes || []).concat(data.notes);
            }

            if (isFirstBatch) {
                // The option backup is captured at offset 0, before any write.
                // Saying so only after the batch came back keeps the claim
                // behind the fact.
                migLog(str('wizard_mig_backup_line', 'Backup made. It is kept until you use it.'));
                (data.notes || []).forEach(function (n) { migLog(n); });
                (data.errors || []).forEach(function (e) { migLog(e, true); });
                renderMigrateList(data.done ? 'done' : 'running');
            }

            updateProgress('running');

            if (data.done) {
                renderMigrateList('done');
                finishMigration(true);
            } else {
                migLog(migCountLine(state.migrationDone));
                runMigrationBatch();
            }
        });
    }

    /**
     * One count line, in the unit the server reports: one per migrated post,
     * plus one per imported redirect and setting. Never in fields - the plan
     * counts fields and a single post can carry several of them.
     *
     * @param {number} n Items the server confirmed so far.
     * @return {string}
     */
    function migCountLine(n) {
        var tpl = (n === 1)
            ? str('wizard_mig_progress_one', '%s item moved over.')
            : str('wizard_mig_progress', '%s items moved over.');
        return fillPlaceholder(tpl, '%s', fmtNumber(n));
    }

    /**
     * The bar shows movement, not a fraction. There is no honest denominator on
     * the client: the plan counts fields, the batch API counts posts, and how
     * many posts sit behind a set of categories is only known server-side. So
     * the bar runs indeterminate until the server says done, and the number
     * beside it is exactly what came back. An estimate is gone for the same
     * reason - it was derived from a total we do not have.
     *
     * @param {string} phase 'running' | 'done' | 'stopped'
     */
    function updateProgress(phase) {
        var running = (phase === 'running');

        if (dom.progressPct) {
            dom.progressPct.textContent = fmtNumber(state.migrationDone);
        }
        if (dom.progressItems) {
            dom.progressItems.textContent = migCountLine(state.migrationDone);
        }
        if (dom.progressBar) {
            // The bar itself is aria-hidden: the count sentence and the log
            // carry the same information in words.
            dom.progressBar.classList.toggle('is-indeterminate', running);
        }
        if (dom.progressFill) {
            // An inline width beats the indeterminate rule, so it is only
            // written when the server really reported done. A stopped run keeps
            // an empty track: nothing may read as "finished".
            dom.progressFill.style.width = (phase === 'done') ? '100%' : '';
        }
    }

    function showMigrationError(msg) {
        dom.errorBox.hidden = false;
        dom.errorMsg.textContent = msg;
    }

    /**
     * Whether everything the run touched came back without a remark. Three
     * things break it, and all three are things the user can see happen:
     * a reported error, a batch stepped over, and a run cut short.
     *
     * @return {boolean}
     */
    function migrationWasClean() {
        return state.migrationErrors.length === 0
            && state.migrationSkipped === 0
            && !state.migrationStopped;
    }

    /**
     * Whether this run put anything on disk. "Clean" and "something happened"
     * are two different questions, and the result card used to answer the
     * second one with the first: a second migration on the same site reports
     * no errors, so it claimed the whole moving plan as moved while it had
     * written nothing at all. The server counts the values it really wrote.
     *
     * @return {boolean}
     */
    function migrationWroteSomething() {
        return state.migrationWritten > 0;
    }

    /**
     * @param {boolean} completed Whether the server reported the run done.
     */
    function finishMigration(completed) {
        updateProgress(completed ? 'done' : 'stopped');

        // The only line that claims a result, and it is written after the run
        // ended. A stopped run already logged its own closing line on the
        // click, so it does not get a second one here.
        if (migrationWasClean() && !migrationWroteSomething()) {
            migLog(str('wizard_mig_nothing_new', 'Done. Everything was already in TamRank, so nothing had to move.'));
        } else if (migrationWasClean()) {
            migLog(fillPlaceholder(str('wizard_mig_all_done', 'Done. %s items moved over.'),
                '%s', fmtNumber(state.migrationDone)));
        } else if (completed) {
            migLog(fillPlaceholder(str('wizard_mig_done_notes', 'Done, with remarks. %s items moved over; the report below says what did not come along.'),
                '%s', fmtNumber(state.migrationDone)));
        }

        // Prepare success screen data
        var stats = [];
        if (state.scanData && state.scanData.categories) {
            state.scanData.categories.forEach(function (cat) {
                if (state.selectedCategories.indexOf(cat.key) > -1) {
                    stats.push({ label: cat.label, count: cat.count });
                }
            });
        }

        renderSuccessScreen(stats);

        // The badge is a claim about the whole run, so a skipped batch or an
        // early stop takes it away just like a reported error does.
        if (dom.zeroErrorsBadge) {
            dom.zeroErrorsBadge.hidden = !migrationWasClean();
        }

        // Enforce minimum-duration so the progress step is always visible
        // (small sites migrate in <100ms which would flash by otherwise).
        var MIN_MIGRATION_VIEW_MS = 1600;
        var elapsedMs = Date.now() - state.migrationStartTime;
        var delay = Math.max(600, MIN_MIGRATION_VIEW_MS - elapsedMs);

        setTimeout(function () {
            goToStep(STEP_DONE);
        }, delay);
    }

    /* ------------------------------------------------------------------
     * Step 3: the report
     * ----------------------------------------------------------------*/

    function renderSuccessScreen(stats) {
        var clean = migrationWasClean();
        // Two conditions, not one. Clean says nothing went wrong; wrote says
        // something happened. Only both together earn the tick and the
        // "moved over" heading - otherwise the block stays what it honestly
        // is: the moving plan.
        var moved = clean && migrationWroteSomething();

        var icon = moved
            ? '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6.5L9 17.5l-5-5"/></svg>'
            : '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/></svg>';

        if (dom.successHead) {
            dom.successHead.textContent = moved
                ? str('wizard_done_results_head', 'Moved over to TamRank')
                : str('wizard_done_results_head_plan', 'From the moving plan');
        }
        if (dom.successBlock) dom.successBlock.classList.toggle('is-plan', !moved);

        var html = '';
        stats.forEach(function (s) {
            html += '<div class="tamrank-res__row">';
            html += icon;
            html += '<span class="tamrank-res__label">' + escHtml(s.label) + '</span>';
            html += '<span class="tamrank-res__num" data-final="' + s.count + '">' + s.count + '</span>';
            html += '</div>';
        });
        dom.successStats.innerHTML = html;
        if (dom.successBlock) dom.successBlock.hidden = stats.length === 0;

        // Migration report: plain-language lines about what happened to the two
        // site-wide settings.
        if (dom.migrationReport) {
            var notes = state.migrationNotes || [];
            if (notes.length) {
                dom.migrationReport.innerHTML = notes.map(function (n) {
                    return '<li>' + escHtml(n) + '</li>';
                }).join('');
                dom.migrationReport.hidden = false;
            } else {
                dom.migrationReport.hidden = true;
            }
        }

        // A run with remarks says so in the heading. Two different remarks:
        // the server reported failing items, or the run never finished.
        if (!clean) {
            if (state.migrationErrors.length > 0) {
                dom.successTitle.textContent = str('migration_complete_with_notes', 'Migration complete with remarks');
                dom.successSub.textContent = migratedAndSkippedLine();
            } else if (state.migrationStopped) {
                dom.successTitle.textContent = str('wizard_done_incomplete_title', 'The migration did not finish.');
                dom.successSub.textContent = fillPlaceholder(str('wizard_done_incomplete_sub', '%s items were moved over. The log says where the migration stopped; the backup is still there.'),
                    '%s', fmtNumber(state.migrationDone));
            } else {
                // The run did reach the end, but a group was stepped over.
                dom.successTitle.textContent = str('migration_complete_with_notes', 'Migration complete with remarks');
                dom.successSub.textContent = fillPlaceholder(str('wizard_done_skipped_sub', '%s items were moved over. One or more groups were skipped; the log says which.'),
                    '%s', fmtNumber(state.migrationDone));
            }
        } else if (!migrationWroteSomething()) {
            // Nothing went wrong and nothing moved: a second run over data that
            // is already here. The old copy congratulated the user on a
            // migration that did not happen.
            dom.successSub.textContent = str('wizard_done_nothing_new_sub', 'Everything in the moving plan was already in TamRank, so nothing was moved over this time.');
        } else if (state.migrationSkippedItems > 0) {
            // A clean run can still leave things behind - a regex redirect, a
            // rule that was already there. That is a remark about the items,
            // not about the run, so the heading stays and the count is said in
            // the subtitle in items, not in server messages.
            dom.successSub.textContent = migratedAndSkippedLine();
        }

        // Quick win based on scan
        if (state.scanData) {
            var tip = getQuickWinTip(state.scanData);
            if (tip) {
                dom.quickWin.hidden = false;
                dom.quickWinText.textContent = tip;
            } else {
                dom.quickWin.hidden = true;
            }
        }

        fillRollbackBlock(stats.length > 0);
    }

    /**
     * "N items migrated, M items skipped." - M counted in items the server left
     * behind, not in the number of messages it sent about them. Rendering
     * migrationErrors.length here is what turned six skipped redirects into
     * "1 items skipped", plural included.
     *
     * @return {string}
     */
    function migratedAndSkippedLine() {
        var line = fmtNumber(state.migrationDone) + ' ' + str('items_migrated', 'items migrated');
        var skipped = state.migrationSkippedItems;
        if (skipped > 0) {
            line += ', ' + fmtNumber(skipped) + ' ' + (skipped === 1
                ? str('item_skipped', 'item skipped')
                : str('items_skipped', 'items skipped'));
        }
        return line + '.';
    }

    /**
     * The way back, on the step where doubt lands. Shown only when this run
     * actually migrated something: the clean-start flow reaches this screen
     * through renderSuccessScreen([]) and has nothing to roll back.
     *
     * @param {boolean} migrated Whether anything was migrated.
     */
    function fillRollbackBlock(migrated) {
        if (!dom.rollbackBlock) return;

        if (!migrated) {
            dom.rollbackBlock.hidden = true;
            return;
        }

        var name = selectedPluginName();

        if (dom.rollbackBody) {
            // "before your first migration", never "this run": the rollback
            // restores every post-meta backup on the site and the backup keeps
            // the first capture, so a second run does not narrow its reach.
            dom.rollbackBody.textContent = trustCopy(
                'wizard_rollback_body',
                'wizard_rollback_body_generic',
                'Roll back the migration. That returns all migrated data to the state from before your first migration. Your %s data was never touched.',
                'Roll back the migration. That returns all migrated data to the state from before your first migration. Your original data was never touched.',
                name
            );
        }
        if (dom.rollbackKeep) {
            dom.rollbackKeep.textContent = trustCopy(
                'wizard_rollback_keep',
                'wizard_rollback_keep_generic',
                'Keep %s active until you are happy with the result. The backup is kept until you use it.',
                'Keep your old SEO plugin active until you are happy with the result. The backup is kept until you use it.',
                name
            );
        }

        // Reset to the resting state, so a re-render after an earlier visit
        // never shows a stale confirm or status line. The disabled flags are
        // part of that reset: migrate -> roll back -> migrate again in one
        // session used to leave both confirm buttons dead, and focus() on a
        // disabled element fails silently.
        if (dom.rollbackActions) dom.rollbackActions.hidden = false;
        if (dom.rollbackConfirm) dom.rollbackConfirm.hidden = true;
        if (dom.rollbackYes) dom.rollbackYes.disabled = false;
        if (dom.rollbackCancel) dom.rollbackCancel.disabled = false;
        if (dom.rollbackStart) dom.rollbackStart.disabled = false;
        if (dom.rollbackStatus) {
            dom.rollbackStatus.hidden = true;
            dom.rollbackStatus.textContent = '';
            dom.rollbackStatus.classList.remove('is-failed');
        }
        dom.rollbackBlock.classList.remove('is-done');
        dom.rollbackBlock.hidden = false;
    }

    /* ------------------------------------------------------------------
     * Rollback from the report step
     *
     * Drives the existing tamrank_onboarding_rollback action. Nothing about
     * the mechanism changes here; this is only the affordance the step was
     * missing.
     * ----------------------------------------------------------------*/

    /**
     * Batches are 50 posts server-side. The cap is a backstop against a batch
     * that never reports done (a backup marker that refuses to delete would
     * otherwise spin forever); at 50 posts a batch it sits far past any real
     * migration.
     */
    var ROLLBACK_MAX_BATCHES = 2000;

    function setRollbackStatus(text, failed) {
        if (!dom.rollbackStatus) return;
        dom.rollbackStatus.textContent = text;
        dom.rollbackStatus.classList.toggle('is-failed', !!failed);
        dom.rollbackStatus.hidden = false;
    }

    /**
     * The rollback could not finish. Give the offer back instead of leaving
     * dead buttons behind: the block returns to its resting state with the
     * failure line above it.
     */
    function rollbackFailed() {
        setRollbackStatus(str('wizard_rollback_failed',
            'The rollback did not complete. Try again, or do it later from Settings, Import and Export.'), true);
        if (dom.rollbackConfirm) dom.rollbackConfirm.hidden = true;
        if (dom.rollbackYes) dom.rollbackYes.disabled = false;
        if (dom.rollbackCancel) dom.rollbackCancel.disabled = false;
        if (dom.rollbackActions) dom.rollbackActions.hidden = false;
        if (dom.rollbackStart) {
            dom.rollbackStart.disabled = false;
            dom.rollbackStart.focus();
        }
    }

    function runRollbackBatch(offset, batchCount) {
        if (batchCount > ROLLBACK_MAX_BATCHES) {
            rollbackFailed();
            return;
        }

        ajax('rollback', { offset: offset }, function (err, data, success) {
            // success === false is a wp_send_json_error - permission_denied
            // sends one with a truthy payload, which used to slip into the
            // success path and hammer on with next_offset undefined.
            if (err || success === false || !data) {
                rollbackFailed();
                return;
            }
            if (data.done) {
                setRollbackStatus(str('wizard_rollback_done',
                    'The migration has been rolled back. All migrated data is back in the state from before your first migration.'));
                if (dom.rollbackBlock) dom.rollbackBlock.classList.add('is-done');
                // The report above describes a migration that no longer exists.
                if (dom.successStats) dom.successStats.innerHTML = '';
                if (dom.successBlock) dom.successBlock.hidden = true;
                if (dom.migrationReport) dom.migrationReport.hidden = true;
                if (dom.zeroErrorsBadge) dom.zeroErrorsBadge.hidden = true;
                // The heading claimed a migration that no longer exists. The
                // quick win does not: it comes from the scan, not from the
                // migration, so it stays.
                var name = selectedPluginName();
                if (dom.successTitle) {
                    dom.successTitle.textContent = str('wizard_done_rolledback_title',
                        'The migration has been rolled back.');
                }
                if (dom.successSub) {
                    dom.successSub.textContent = trustCopy(
                        'wizard_done_rolledback_subtitle',
                        'wizard_done_rolledback_subtitle_generic',
                        'There is no migrated data left in TamRank. Your %s data was never touched.',
                        'There is no migrated data left in TamRank. Your original data was never touched.',
                        name
                    );
                }
                if (dom.rollbackStatus) dom.rollbackStatus.focus();
                return;
            }
            // The offset is the server's own progress counter and the only
            // thing that makes this loop end on a healthy server: every
            // working batch answers with the offset it was sent plus the batch
            // size. A reply that carries no number, or one that does not move
            // forward, is a server that is not making progress — sending the
            // same request 2000 more times only postpones the same conclusion,
            // and with an undefined offset those are 2000 requests the server
            // reads as "start over". So stop here, on the first reply that
            // cannot be a step forward, and hand the user the retry.
            var next = data.next_offset;
            if (typeof next !== 'number' || !isFinite(next) || next <= offset) {
                rollbackFailed();
                return;
            }
            runRollbackBatch(next, batchCount + 1);
        });
    }

    function initRollback() {
        if (dom.rollbackStart) {
            dom.rollbackStart.addEventListener('click', function () {
                if (dom.rollbackActions) dom.rollbackActions.hidden = true;
                if (dom.rollbackConfirm) dom.rollbackConfirm.hidden = false;
                // The confetti belongs to the success, not to the doubt.
                clearConfetti();
                if (dom.rollbackYes) dom.rollbackYes.focus();
            });
        }
        if (dom.rollbackCancel) {
            dom.rollbackCancel.addEventListener('click', cancelRollbackConfirm);
        }
        if (dom.rollbackYes) {
            dom.rollbackYes.addEventListener('click', function () {
                dom.rollbackYes.disabled = true;
                if (dom.rollbackCancel) dom.rollbackCancel.disabled = true;
                // Hide the question: leaving it up with two dead buttons reads
                // as a broken screen while the batches run.
                if (dom.rollbackConfirm) dom.rollbackConfirm.hidden = true;
                if (dom.rollbackActions) dom.rollbackActions.hidden = true;
                setRollbackStatus(str('wizard_rollback_running', 'Rolling back...'));
                runRollbackBatch(0, 1);
            });
        }
    }

    function cancelRollbackConfirm() {
        if (dom.rollbackConfirm) dom.rollbackConfirm.hidden = true;
        if (dom.rollbackActions) dom.rollbackActions.hidden = false;
        if (dom.rollbackStart) dom.rollbackStart.focus();
    }

    function getQuickWinTip(scan) {
        if (scan.missing_desc > 0) {
            return fillPlaceholder(str('tip_missing_desc', 'You have %s pages without a meta description. Add them quickly in the editor.'), '%s', scan.missing_desc);
        }
        if (scan.duplicate_titles > 0) {
            return fillPlaceholder(str('tip_duplicate_titles', 'There are %s pages with the same title. Unique titles help search engines distinguish your pages better.'), '%s', scan.duplicate_titles);
        }
        if (scan.schema_pct < 50) {
            return fillPlaceholder(str('tip_schema_pct', 'Only %s% of your pages have schema markup. Enable automatic schema detection in TamRank settings.'), '%s', scan.schema_pct);
        }
        return null;
    }

    function initEmailOptin() {
        var card        = document.getElementById('tamrank-email-optin-card');
        var row         = document.getElementById('tamrank-email-optin-row');
        var checkbox    = document.getElementById('tamrank-email-optin-checkbox');
        var personaBlk  = document.getElementById('tamrank-persona-block');
        if (!checkbox || !personaBlk || !card) return;

        checkbox.addEventListener('change', function () {
            personaBlk.hidden = !checkbox.checked;
            if (row) row.classList.toggle('is-checked', checkbox.checked);

            // If user un-checks, clear any persona selection so we don't ship
            // a track for someone who said no.
            if (!checkbox.checked) {
                var radios = personaBlk.querySelectorAll('.tamrank-optin__persona-radio');
                radios.forEach(function (r) { r.checked = false; });
                personaBlk.querySelectorAll('.tamrank-optin__persona-card').forEach(function (c) {
                    c.classList.remove('is-selected');
                });
            }
        });

        // Custom radio-cards — clicking the label sets is-selected on the
        // wrapper so the styled bullet updates without relying on
        // (browser-inconsistent) :checked + sibling selectors on hidden inputs.
        personaBlk.querySelectorAll('.tamrank-optin__persona-card').forEach(function (cardEl) {
            var radio = cardEl.querySelector('.tamrank-optin__persona-radio');
            if (!radio) return;
            cardEl.addEventListener('click', function () {
                personaBlk.querySelectorAll('.tamrank-optin__persona-card').forEach(function (c) {
                    c.classList.remove('is-selected');
                });
                cardEl.classList.add('is-selected');
                radio.checked = true;
            });
        });
    }

    // Arm the FREE onboarding tour. The tour engine (loaded on every TamRank
    // admin page) auto-starts when tamrank_tour_step >= 0, so the rondleiding
    // begins the moment the user lands on a TamRank page. Pure localStorage —
    // no external request (privacy-by-design). Armed on BOTH finishing and
    // skipping the wizard, so a user who skips setup still gets the feature tour.
    function armFreeTour() {
        try { localStorage.setItem('tamrank_tour_step', '0'); } catch (e) {}
    }

    function markComplete() {
        var optinCheckbox = document.getElementById('tamrank-email-optin-checkbox');
        var acceptEmailTips = optinCheckbox && optinCheckbox.checked ? '1' : '0';

        // Persona track is only valid when the user opted in. Skip = empty.
        var personaTrack = '';
        if (optinCheckbox && optinCheckbox.checked) {
            var selectedRadio = document.querySelector('.tamrank-optin__persona-radio:checked');
            if (selectedRadio) personaTrack = selectedRadio.value;
        }

        armFreeTour();

        ajax('complete', {
            accept_email_tips: acceptEmailTips,
            persona_track:     personaTrack
        }, function () {
            // Silent — option saved server-side
        });
    }

    /* ------------------------------------------------------------------
     * Peak moment on the report (Branding.md §Motion & Feedback)
     *
     * Count-up of the figures, a check that draws itself in, and restrained
     * confetti: 18 particles in brand colours, shaped like the rounded bars of
     * the TamRank icon, falling softly, once. Reduced motion gets the end
     * state directly and no confetti at all.
     * ----------------------------------------------------------------*/

    var CONFETTI_COLORS = ['#10b981', '#059669', '#14b8a6', '#94a3b8', '#cbd5e1'];

    function clearConfetti() {
        if (dom.confetti) dom.confetti.innerHTML = '';
    }

    function fireConfetti() {
        if (!dom.confetti || prefersReducedMotion) return;
        var wrap = dom.confetti;
        wrap.innerHTML = '';
        for (var i = 0; i < 18; i++) {
            var d = document.createElement('i');
            var drift = (Math.random() - 0.5) * 90;
            var fall = 260 + Math.random() * 220;
            d.style.left = (4 + Math.random() * 92) + '%';
            d.style.background = CONFETTI_COLORS[i % CONFETTI_COLORS.length];
            d.style.setProperty('--r0', Math.round(Math.random() * 180 - 90) + 'deg');
            d.style.setProperty('--r1', Math.round(Math.random() * 540 - 270) + 'deg');
            d.style.setProperty('--dx', Math.round(drift) + 'px');
            d.style.setProperty('--dy', Math.round(fall) + 'px');
            d.style.setProperty('--dur', (1.5 + Math.random() * 0.7).toFixed(2) + 's');
            d.style.setProperty('--delay', (Math.random() * 0.45).toFixed(2) + 's');
            wrap.appendChild(d);
        }
        // Clean up after the last particle has faded, so the sheet does not
        // carry 18 dead nodes for the rest of the session.
        setTimeout(clearConfetti, 2800);
    }

    function countUpFigures() {
        var nums = document.querySelectorAll('#tamrank-success-stats .tamrank-res__num');
        Array.prototype.forEach.call(nums, function (el) {
            var final = parseInt(el.dataset.final, 10);
            if (isNaN(final)) return;
            if (prefersReducedMotion) { el.textContent = final; return; }
            var start = null;
            function tick(ts) {
                if (!start) start = ts;
                var p = Math.min((ts - start) / 750, 1);
                el.textContent = Math.round(final * (1 - Math.pow(1 - p, 3)));
                if (p < 1) requestAnimationFrame(tick);
            }
            requestAnimationFrame(tick);
        });
    }

    function playPeakMoment() {
        countUpFigures();
        var badge = dom.zeroErrorsBadge;
        if (badge && !badge.hidden && !prefersReducedMotion) {
            badge.classList.remove('is-drawing');
            void badge.offsetWidth;
            badge.classList.add('is-drawing');
        }
        // The confetti is a claim like any other line on this step. A run that
        // stopped early, skipped a group or reported errors does not get one.
        if (migrationWasClean()) {
            fireConfetti();
        }
    }

    /* ------------------------------------------------------------------
     * Skip handler
     * ----------------------------------------------------------------*/

    function handleSkip(e) {
        e.preventDefault();

        // Skipping the setup wizard should not cost the user the feature tour.
        // The server keeps tam_rank_tour_done_free unset on skip, so arming the
        // engine here makes the FREE tour start on the dashboard all the same.
        armFreeTour();

        ajax('skip', null, function (err, data) {
            if (data && data.redirect) {
                window.location.href = data.redirect;
            } else {
                window.location.href = cfg.dashUrl || '/wp-admin/';
            }
        });
    }

    /* ------------------------------------------------------------------
     * Utility
     * ----------------------------------------------------------------*/

    function escHtml(str_) {
        var div = document.createElement('div');
        div.appendChild(document.createTextNode(str_ || ''));
        return div.innerHTML;
    }

    /* ------------------------------------------------------------------
     * Event binding
     * ----------------------------------------------------------------*/

    function bindEvents() {
        // Skip
        if (dom.skipBtn) {
            dom.skipBtn.addEventListener('click', handleSkip);
        }

        // Language switcher (header)
        if (dom.langSwitcher) {
            dom.langSwitcher.addEventListener('click', function (e) {
                var btn = e.target.closest('.tamrank-wizard__lang-btn');
                if (!btn) return;
                var chosen = btn.dataset.lang;
                if (!chosen || chosen === state.languageSelected) return;
                // Optimistic UI
                dom.langSwitcher.querySelectorAll('.tamrank-wizard__lang-btn').forEach(function (b) {
                    b.classList.remove('is-selected');
                    b.setAttribute('aria-pressed', 'false');
                });
                btn.classList.add('is-selected');
                btn.setAttribute('aria-pressed', 'true');
                state.languageSelected = chosen;
                // Persist + reload so all PHP/JS strings render in new language
                ajax('set_language', { language: chosen }, function () {
                    var url = new URL(window.location.href);
                    url.searchParams.set('step', String(state.currentStep));
                    window.location.href = url.toString();
                });
            });
        }

        // Detection -> moving plan, or straight to the report on a clean start.
        if (dom.detectNext) {
            dom.detectNext.addEventListener('click', function () {
                var src = getSelectedSource();
                var willMigrate = state.chosenFlow === 'migrate'
                    && src && src.plugin && src.data_count > 0;
                if (willMigrate) {
                    goToStep(STEP_PLAN);
                    return;
                }
                // Clean start (or no migratable data): there is no plan to make.
                // The scan still runs, because the report's quick win reads it.
                dom.detectNext.disabled = true;
                runScan(function () {
                    dom.detectNext.disabled = false;
                    renderSuccessScreen([]);
                    dom.successTitle.textContent = str('ready_to_use', 'TamRank is ready to use!');
                    dom.successSub.textContent = str('ready_to_use_desc', 'Your site has been scanned and TamRank is ready to improve your SEO.');
                    if (dom.zeroErrorsBadge) dom.zeroErrorsBadge.hidden = true;
                    goToStep(STEP_DONE);
                });
            });
        }

        if (dom.scanRetry) {
            dom.scanRetry.addEventListener('click', retryScan);
        }

        // Category toggle (step 1). Delegated, and bound HERE rather than in
        // buildCategories(): that function runs on every entry into the step,
        // while dom.categories is cached once and only its innerHTML is
        // swapped. Binding per render stacked a listener per visit.
        if (dom.categories) {
            dom.categories.addEventListener('click', function (e) {
                var label = e.target.closest('.tamrank-plan__row[data-cat]');
                if (!label) return;
                e.preventDefault();
                var key = label.dataset.cat;
                var idx = state.selectedCategories.indexOf(key);
                var input = label.querySelector('input');
                if (idx > -1) {
                    state.selectedCategories.splice(idx, 1);
                    label.classList.remove('is-checked');
                    if (input) input.checked = false;
                } else {
                    state.selectedCategories.push(key);
                    label.classList.add('is-checked');
                    if (input) input.checked = true;
                }
                syncPlan();
            });

            // Same reason the listener above lives here: the container survives
            // every re-render, so one listener is enough for the lifetime of the
            // step.
            dom.categories.addEventListener('scroll', updateCategoryFade, { passive: true });
        }

        // The budget is a vh value and the row height follows the compact
        // breakpoint, so both sides of the trim change when the window does.
        // Debounced, because a resize fires continuously while dragging.
        window.addEventListener('resize', function () {
            clearTimeout(state.categoryFitTimer);
            state.categoryFitTimer = setTimeout(fitCategoryList, 150);
        });

        // Select all / select nothing
        if (dom.planToggle) {
            dom.planToggle.addEventListener('click', function () {
                var rows = dom.categories.querySelectorAll('.tamrank-plan__row[data-cat]');
                var anyOn = Array.prototype.some.call(rows, function (r) {
                    return r.classList.contains('is-checked');
                });
                state.selectedCategories = [];
                Array.prototype.forEach.call(rows, function (r) {
                    var input = r.querySelector('input');
                    if (anyOn) {
                        r.classList.remove('is-checked');
                        if (input) input.checked = false;
                    } else {
                        r.classList.add('is-checked');
                        if (input) input.checked = true;
                        state.selectedCategories.push(r.dataset.cat);
                    }
                });
                syncPlan();
            });
        }

        // Migration start
        if (dom.migrateStart) {
            dom.migrateStart.addEventListener('click', function () {
                var cats = (state.scanData && state.scanData.categories) ? state.scanData.categories : [];
                var nothingToMigrate = !state.detection || !state.detection.plugin || !cats.length;
                if (nothingToMigrate) {
                    // Skip migration — go directly to the report
                    state.chosenFlow = 'clean';
                    renderSuccessScreen([]);
                    dom.successTitle.textContent = str('ready_to_use', 'TamRank is ready to use!');
                    dom.successSub.textContent = str('ready_to_use_desc', 'Your site has been scanned and TamRank is ready to improve your SEO.');
                    if (dom.zeroErrorsBadge) dom.zeroErrorsBadge.hidden = true;
                    goToStep(STEP_DONE);
                } else {
                    startMigration();
                }
            });
        }

        // Back buttons
        document.querySelectorAll('[data-prev]').forEach(function (btn) {
            btn.addEventListener('click', function () {
                goToStep(state.currentStep - 1);
            });
        });

        // Error retry/skip
        if (dom.errorRetry) {
            dom.errorRetry.addEventListener('click', function () {
                dom.errorBox.hidden = true;
                runMigrationBatch();
            });
        }
        if (dom.errorSkip) {
            dom.errorSkip.addEventListener('click', function () {
                dom.errorBox.hidden = true;
                // Step over the batch, count nothing: those posts were not
                // migrated, so adding them to the running total would claim a
                // move that never happened.
                state.migrationOffset += MIGRATION_BATCH_SIZE;
                state.migrationSkipped++;
                migLog(str('wizard_mig_batch_skipped', 'Group skipped. Those items were not moved over.'), true);
                runMigrationBatch();
            });
        }
        if (dom.errorStop) {
            dom.errorStop.addEventListener('click', function () {
                dom.errorBox.hidden = true;
                state.migrationStopped = true;
                migLog(fillPlaceholder(str('wizard_mig_stopped', 'Stopped. %s items moved over; what was already moved over stays put, and so does the backup.'),
                    '%s', fmtNumber(state.migrationDone)), true);
                finishMigration(false);
            });
        }

        // Keyboard nav. Escape cancels the rollback confirm first: closing the
        // question the user just opened is what Escape means there; only
        // outside that state does it fall through to leaving the wizard.
        document.addEventListener('keydown', function (e) {
            if (e.key !== 'Escape') return;
            if (dom.rollbackConfirm && !dom.rollbackConfirm.hidden) {
                cancelRollbackConfirm();
                return;
            }
            handleSkip(e);
        });
    }

    /* ------------------------------------------------------------------
     * Init
     * ----------------------------------------------------------------*/

    function init() {
        cacheDom();
        bindEvents();
        initEmailOptin();
        initRollback();

        // Optional resume-step via URL param (e.g. after language reload)
        var params = new URLSearchParams(window.location.search);
        var startStep = parseInt(params.get('step'), 10);
        if (startStep > 0 && startStep < state.totalSteps) {
            dom.panels[0].classList.remove('is-active');
            dom.panels[startStep].classList.add('is-active');
            updateStepper(startStep);
            state.currentStep = startStep;
            // Detection only ever runs on step 0, so resuming past it left
            // state.detection null — and everything downstream reads it.
            // Run it first, then enter the step. This costs no extra request
            // on the normal path, which never takes this branch.
            runDetection(function () {
                onStepEnter(startStep);
            });
        } else {
            onStepEnter(STEP_DETECT);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();
