<?php
/**
 * Plugin Name:       abtestkit
 * Plugin URI:        https://www.abtestkit.io/
 * Description:       Increase WooCommerce Revenue with A/B Testing. Track Real Sales, Not Just Clicks.
 * Version:           1.3.3
 * Author:            abtestkit
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Requires at least: 6.3
 * Tested up to:      6.9
 * Requires PHP:      7.4
 * Text Domain:       abtestkit
 * Domain Path:       /languages
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// Define the custom events table as a constant so PHPCS doesn't flag variables in SQL.
if ( ! defined( 'ABTESTKIT_EVENTS_TABLE' ) ) {
    global $wpdb;
    define( 'ABTESTKIT_EVENTS_TABLE', $wpdb->prefix . 'abtestkit_events' );
}


//ini_set('log_errors', 1);
//ini_set('error_log', __DIR__ . '/my-abtestkit-debug.log');

// ─────────────────────────────────────────────────────────────────────────────
// Telemetry (anonymous, opt-in, one-shot milestones) – DISABLED
// ─────────────────────────────────────────────────────────────────────────────
if ( ! defined( 'ABTESTKIT_TELEMETRY_ENDPOINT' ) ) {
    // Telemetry has been disabled – constant defined but empty
    define( 'ABTESTKIT_TELEMETRY_ENDPOINT', '' );
}

// ─────────────────────────────────────────────────────────────
// Email capture (post-first-launch) – DISABLED
// ─────────────────────────────────────────────────────────────
if ( ! defined( 'ABTESTKIT_EMAIL_CAPTURE_ENABLED' ) ) {
    define( 'ABTESTKIT_EMAIL_CAPTURE_ENABLED', false ); // telemetry/email capture disabled globally
}

// Defined (empty) so any code reading it doesn't break.
if ( ! defined( 'ABTESTKIT_EMAIL_APPS_SCRIPT' ) ) {
    define( 'ABTESTKIT_EMAIL_APPS_SCRIPT', '' );
}


// Re-use telemetry endpoint by default (can be a different Apps Script if you like)
if ( ! defined( 'ABTESTKIT_EMAIL_APPS_SCRIPT' ) ) {
    define( 'ABTESTKIT_EMAIL_APPS_SCRIPT', ABTESTKIT_TELEMETRY_ENDPOINT );
}

define( 'ABTESTKIT_TELEMETRY_OPTIN_OPTION',   'abtestkit_telemetry_opted_in' );
define( 'ABTESTKIT_TELEMETRY_FLAGS_OPTION',   'abtestkit_telemetry_flags' );
define( 'ABTESTKIT_TELEMETRY_INSTALL_OPTION', 'abtestkit_telemetry_installed_at' );

function abtestkit_get_telemetry_decision(): string {
    $value = get_option( ABTESTKIT_TELEMETRY_OPTIN_OPTION, '' );

    if ( $value === true || $value === 1 || $value === '1' || $value === 'yes' ) {
        return 'yes';
    }

    if ( $value === false || $value === 0 || $value === '0' || $value === 'no' ) {
        return 'no';
    }

    return '';
}

function abtestkit_is_telemetry_opted_in(): bool {
    return abtestkit_get_telemetry_decision() === 'yes';
}

function abtestkit_set_telemetry_optin( bool $yes ) {
    update_option( ABTESTKIT_TELEMETRY_OPTIN_OPTION, $yes ? 'yes' : 'no' );

    // Ensure heartbeat schedule exists (sender itself is opt-in gated).
    if ( function_exists( 'abtestkit_telemetry_schedule_heartbeat' ) ) {
        abtestkit_telemetry_schedule_heartbeat();
    }

    if ( ! $yes ) {
        return;
    }

    $flags = get_option( ABTESTKIT_TELEMETRY_FLAGS_OPTION, [] );
    $flags = is_array( $flags ) ? $flags : [];

    if ( empty( $flags['opted_in_sent'] ) ) {
        abtestkit_send_telemetry( 'telemetry_opted_in', [ 'value' => 1 ] );
        $flags['opted_in_sent'] = true;
    }

    if ( empty( $flags['installed_sent'] ) ) {
        abtestkit_send_telemetry( 'plugin_installed', [
            'installed_at' => (int) get_option( ABTESTKIT_TELEMETRY_INSTALL_OPTION, time() ),
        ] );
        $flags['installed_sent'] = true;
    }

    update_option( ABTESTKIT_TELEMETRY_FLAGS_OPTION, $flags );
}
function abtestkit_get_flags(): array {
    $f = get_option( ABTESTKIT_TELEMETRY_FLAGS_OPTION, [] );
    return is_array( $f ) ? $f : [];
}
function abtestkit_mark_flag( string $key ) {
    $f = abtestkit_get_flags();
    $f[ $key ] = true;
    update_option( ABTESTKIT_TELEMETRY_FLAGS_OPTION, $f );
}
function abtestkit_flag_is_set( string $key ): bool {
    $f = abtestkit_get_flags();
    return ! empty( $f[ $key ] );
}
function abtestkit_build_telemetry_base(): array {
    return [
        'plugin'   => 'abtestkit',
        'version'  => '1.3.3',
        'site'     => md5( home_url() ), // anonymous hash
        'wp'       => get_bloginfo( 'version' ),
        'php'      => PHP_VERSION,
        'env'      => ( wp_get_environment_type() ?: 'production' ),
    ];
}
/**
 * Telemetry sender (anonymous, opt-in).
 *
 * Set ABTESTKIT_TELEMETRY_ENDPOINT
 *   add_filter('abtestkit_telemetry_endpoint')
 */
if ( ! defined( 'ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK' ) ) {
    define( 'ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK', 'abtestkit_telemetry_heartbeat' );
}
if ( ! defined( 'ABTESTKIT_TELEMETRY_TESTS_CREATED_OPTION' ) ) {
    define( 'ABTESTKIT_TELEMETRY_TESTS_CREATED_OPTION', 'abtestkit_telemetry_tests_created' );
}

function abtestkit_telemetry_endpoint(): string {
    // If the constant is defined but empty (your current default), fall back.
    $endpoint = defined( 'ABTESTKIT_TELEMETRY_ENDPOINT' ) ? (string) ABTESTKIT_TELEMETRY_ENDPOINT : '';
    $endpoint = trim( $endpoint );

    if ( $endpoint === '' ) {
        // Default collector
        $endpoint = 'https://www.abtestkit.io/wp-json/abtestkit-telemetry/v1/collect';
    }

    $endpoint = (string) apply_filters( 'abtestkit_telemetry_endpoint', $endpoint );
    $endpoint = esc_url_raw( trim( $endpoint ) );

    return $endpoint;
}

/**
 * Internal sender:
 * - $force=false => normal telemetry (requires full opt-in)
 * - $force=true  => explicit user-action events only (e.g. deactivate feedback)
 */
function abtestkit_send_telemetry_raw(
    string $event,
    array $data = [],
    bool $force = false,
    bool $blocking = false,
    int $timeout = 2
): void {

    // Normal telemetry is hard opt-in gated.
    if ( ! $force && ! abtestkit_is_telemetry_opted_in() ) {
        return;
    }

    $endpoint = abtestkit_telemetry_endpoint();
    if ( $endpoint === '' ) {
        return;
    }

    $event = sanitize_key( $event );

    // Keep payload small + anonymous.
    $payload = array_merge(
        abtestkit_build_telemetry_base(),
        [
            'event' => $event,
            't'     => time(),
            'data'  => is_array( $data ) ? $data : [],
        ]
    );

    $args = [
        'timeout'  => max( 1, (int) $timeout ),
        'blocking' => (bool) $blocking,
        'headers'  => [
            'Content-Type' => 'application/json; charset=utf-8',
        ],
        'body'     => wp_json_encode( $payload ),
    ];

    // Best-effort; never break UX.
    try {
        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_post_wp_remote_post
        wp_remote_post( $endpoint, $args );
    } catch ( \Throwable $e ) {
        // no-op
    }
}

/**
 * Public sender: full telemetry only (opt-in gated).
 */
function abtestkit_send_telemetry( string $event, array $data = [] ) {
    abtestkit_send_telemetry_raw( $event, $data, false, false, 2 );
}

/**
 * Forced sender: ONLY for explicit user actions where submitting implies opt-in for that one event.
 * Keep this whitelist tight.
 */
function abtestkit_send_telemetry_forced( string $event, array $data = [], bool $blocking = true ): void {
    $event = sanitize_key( $event );

    // Tight allow-list to prevent accidental “forced” telemetry expansion.
    $allowed = [ 'plugin_deactivated' ];
    if ( ! in_array( $event, $allowed, true ) ) {
        return;
    }

    // For deactivation feedback, use blocking=true so it actually leaves the site
    // before the user completes deactivation.
    abtestkit_send_telemetry_raw( $event, $data, true, $blocking, 3 );
}

/**
 * Heartbeat: scheduled daily via WP-Cron.
 * (Schedule exists regardless; sender is opt-in gated.)
 */
function abtestkit_telemetry_schedule_heartbeat(): void {
    if ( ! wp_next_scheduled( ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK ) ) {
        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
    }
}

function abtestkit_telemetry_unschedule_heartbeat(): void {
    $ts = wp_next_scheduled( ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
    while ( $ts ) {
        wp_unschedule_event( $ts, ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
        $ts = wp_next_scheduled( ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK );
    }
}

add_action( ABTESTKIT_TELEMETRY_HEARTBEAT_HOOK, function() {
    if ( ! abtestkit_is_telemetry_opted_in() ) return;

    $tests_total   = 0;
    $tests_running = 0;
    $all = abtestkit_pt_all();

    if ( is_array( $all ) ) {
        $tests_total = count( $all );
        foreach ( $all as $t ) {
            if ( is_array( $t ) && ( $t['status'] ?? '' ) === 'running' ) {
                $tests_running++;
            }
        }
    }

    abtestkit_send_telemetry( 'heartbeat', [
        'tests_total'   => (int) $tests_total,
        'tests_running' => (int) $tests_running,
        'tests_created' => (int) get_option( ABTESTKIT_TELEMETRY_TESTS_CREATED_OPTION, 0 ),
    ] );
} );

/**
 * Privacy-safe hashing helpers (never send raw selectors/URLs).
 */
function abtestkit_telemetry_hash_targets( array $targets ): array {
    $out = [];
    foreach ( $targets as $t ) {
        $t = trim( (string) $t );
        if ( $t === '' ) continue;
        $out[] = substr( md5( $t ), 0, 12 );
        if ( count( $out ) >= 5 ) break;
    }
    return $out;
}

function abtestkit_telemetry_guess_target_kind( array $targets ): string {
    foreach ( $targets as $t ) {
        $t = ltrim( (string) $t );
        if ( $t === '' ) continue;
        if ( strpos( $t, '://' ) !== false ) return 'url';
        if ( preg_match( '/^[.#\\[]/', $t ) ) return 'selector';
        if ( preg_match( '/\\s|>/', $t ) ) return 'selector';
    }
    return '';
}

function abtestkit_telemetry_tests_created_count(): int {
    return (int) get_option( ABTESTKIT_TELEMETRY_TESTS_CREATED_OPTION, 0 );
}

function abtestkit_telemetry_inc_tests_created(): int {
    $n = abtestkit_telemetry_tests_created_count();
    $n++;
    update_option( ABTESTKIT_TELEMETRY_TESTS_CREATED_OPTION, $n, false );
    return $n;
}

function abtestkit_telemetry_send_once( string $flag_key, string $event, array $data = [] ): void {
    if ( abtestkit_flag_is_set( $flag_key ) ) return;
    abtestkit_send_telemetry( $event, $data );
    abtestkit_mark_flag( $flag_key );
}

/**
 * Event: plugin deactivated feedback from plugins.php.
 * Fires only when the user actively submits feedback or skips, and only if opted-in.
 */
/**
 * Event: plugin deactivated feedback from plugins.php.
 * This is NOT full telemetry opt-in.
 * We only send when the user explicitly submits feedback (not skip/empty).
 */
function abtestkit_telemetry_track_plugin_delete_reason( string $reason, string $detail = '' ): void {
    $reason = sanitize_key( (string) $reason );
    $detail = sanitize_text_field( (string) $detail );

    // Only treat "Send feedback" as opt-in for this one event.
    if ( $reason === '' || $reason === 'skip' ) {
        return;
    }

    abtestkit_send_telemetry_forced(
        'plugin_deactivated',
        [
            'kind'   => 'deactivate',
            'reason' => $reason,
            'detail' => $detail,
        ],
        true
    );
}

/**
 * Milestone: first time opening the Create Test wizard (and first time opening AFTER first test created).
 */
function abtestkit_telemetry_track_pt_wizard_opened(): void {
    if ( ! abtestkit_is_telemetry_opted_in() ) return;

    $created = abtestkit_telemetry_tests_created_count();

    abtestkit_telemetry_send_once(
        'pt_wizard_opened_first',
        'pt_wizard_opened',
        [ 'tests_created' => (int) $created ]
    );

    if ( $created > 0 ) {
        abtestkit_telemetry_send_once(
            'pt_wizard_opened_after_first',
            'pt_wizard_opened_after_first',
            [ 'tests_created' => (int) $created ]
        );
    }
}

/**
 * Event: successful PT test creation (fires every time, plus 1st/2nd milestones).
 */
function abtestkit_telemetry_track_test_created( array $test, array $context = [] ): void {
    if ( ! abtestkit_is_telemetry_opted_in() ) return;

    $kind = isset( $test['kind'] ) ? sanitize_key( (string) $test['kind'] ) : '';
    $goal = isset( $test['goal'] ) ? sanitize_key( (string) $test['goal'] ) : '';
    $mode = isset( $test['decision_mode'] ) ? sanitize_key( (string) $test['decision_mode'] ) : '';
    $rule = isset( $test['decision_rule'] ) ? sanitize_key( (string) $test['decision_rule'] ) : '';

    $links = [];
    if ( ! empty( $test['links'] ) && is_array( $test['links'] ) ) {
        $links = array_values( array_map( 'strval', $test['links'] ) );
    }

    $count = abtestkit_telemetry_inc_tests_created();

    $data = [
        'tests_created' => (int) $count,
        'kind'          => $kind,
        'goal'          => $goal,
        'decision_mode' => $mode,
        'decision_rule' => $rule,
        'started'       => ( ( $test['status'] ?? '' ) === 'running' ) ? 1 : 0,
        'split'         => isset( $test['split'] ) ? (int) $test['split'] : 0,

        // CTA targets: hashed only
        'cta_count'     => (int) count( $links ),
        'cta_kind'      => abtestkit_telemetry_guess_target_kind( $links ),
        'cta_hashes'    => abtestkit_telemetry_hash_targets( $links ),
    ];

    // Optional request context (non-sensitive)
    foreach ( [ 'b_mode', 'seo_safe_existing_b' ] as $k ) {
        if ( array_key_exists( $k, $context ) ) {
            $data[ $k ] = is_scalar( $context[ $k ] ) ? $context[ $k ] : '';
        }
    }

    abtestkit_send_telemetry( 'pt_test_created', $data );

    if ( $count === 1 ) {
        abtestkit_telemetry_send_once( 'pt_first_test_created', 'pt_first_test_created', $data );
    } elseif ( $count === 2 ) {
        abtestkit_telemetry_send_once( 'pt_second_test_created', 'pt_second_test_created', $data );
    }
}

// ───────────────────────────────────────────────────────────
// One-time redirect + "onboarding complete" flag (hardened)
// ───────────────────────────────────────────────────────────
register_activation_hook( __FILE__, 'abtestkit_activate_onboarding' );
function abtestkit_activate_onboarding() {
    // Create the "done" flag if missing
    if ( get_option( 'abtestkit_onboarding_done', null ) === null ) {
        add_option( 'abtestkit_onboarding_done', '0' );
    }
    // Set the one-time redirect flag
    update_option( 'abtestkit_do_activation_redirect', '1', true );
}

/**
 * Some activation paths don’t reliably run the activation callback in the
 * same request pipeline that lands you back in wp-admin. This ensures the
 * flag is still set right after the specific plugin is activated.
 */
add_action( 'activated_plugin', function( $plugin ) {
    if ( $plugin === plugin_basename( __FILE__ ) ) {
        update_option( 'abtestkit_do_activation_redirect', '1', true );
    }
}, 10, 1 );

/**
 * Perform the one-time redirect as soon as we’re safely in admin.
 */
add_action( 'admin_init', function () {
    // Only once
    if ( '1' !== get_option( 'abtestkit_do_activation_redirect' ) ) {
        return;
    }

    // Clear the flag immediately to avoid loops
    delete_option( 'abtestkit_do_activation_redirect' );

    // Don’t redirect in these contexts
    if ( wp_doing_ajax() ) return;
    if ( is_network_admin() ) return; // Multisite network screens
    // If we're on the bulk activate action, bail early. Core has already handled nonce checks.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check of a core GET param.
    if ( isset( $_GET['activate-multi'] ) ) {
	    return;
    }

    // Only redirect admins who can actually view the page
    if ( ! current_user_can( 'manage_options' ) ) return;

    // If onboarding is already complete (or tests already exist), skip it.
    $done = ( '1' === get_option( 'abtestkit_onboarding_done' ) );
    $tests = abtestkit_pt_all();
    $has_tests = is_array( $tests ) && ! empty( $tests );

    if ( $done || $has_tests ) {
        wp_safe_redirect( admin_url( 'admin.php?page=abtestkit-dashboard' ) );
        exit;
    }

    // Otherwise, show onboarding once.
    wp_safe_redirect( admin_url( 'admin.php?page=abtestkit-get-started' ) );
    exit;
} );

// ───────────────────────────────────────────────────────────
// Hidden admin pages
// ───────────────────────────────────────────────────────────
add_action( 'admin_menu', function () {
    // "Create new test +" item under main abtestkit menu → points to the PT wizard
    add_submenu_page(
        'abtestkit-dashboard', // parent = main abtestkit menu
        __( 'Create New Test – abtestkit', 'abtestkit' ),
        __( 'Create new test +', 'abtestkit' ),
        'manage_options',
        'abtestkit-pt-wizard',
        'abtestkit_render_pt_wizard_page'
    );

    // Page Test Wizard (hidden – direct URL only)
    add_submenu_page(
        null, // hidden from menus
        __( 'Create Page Test', 'abtestkit' ),
        __( 'Create Page Test', 'abtestkit' ),
        'manage_options',
        'abtestkit-pt-wizard',
        'abtestkit_render_pt_wizard_page'
    );

    // Onboarding "Get started" screen (hidden – direct URL only)
    add_submenu_page(
        null,
        __( 'Welcome to abtestkit', 'abtestkit' ),
        __( 'Welcome to abtestkit', 'abtestkit' ),
        'manage_options',
        'abtestkit-get-started',
        'abtestkit_render_onboarding_page'
    );

    // Test Performance page (hidden – direct URL only)
    add_submenu_page(
        null,
        __( 'Test Performance', 'abtestkit' ),
        __( 'Test Performance', 'abtestkit' ),
        'manage_options',
        'abtestkit-test-performance',
        'abtestkit_render_test_performance_page'
    );
} );

function abtestkit_render_onboarding_page() {
    echo '<div id="abtestkit-onboarding-root"></div>';
}

// ───────────────────────────────────────────────────────────
// Enqueue wizard assets on that page
// ───────────────────────────────────────────────────────────
add_action( 'admin_enqueue_scripts', function ( $hook ) {
    if ( 'admin_page_abtestkit-get-started' !== $hook ) {
        return;
    }

    // Core deps
    wp_enqueue_script( 'wp-element' );
    wp_enqueue_script( 'wp-components' );
    wp_enqueue_script( 'wp-api-fetch' );
    wp_enqueue_style( 'wp-components' );

    // Onboarding wizard JS
    wp_enqueue_script(
        'abtestkit-onboarding',
        plugins_url( 'assets/js/onboarding.js', __FILE__ ),
        array( 'wp-element', 'wp-components', 'wp-api-fetch' ),
        '1.3.3',
        true
    );

    wp_localize_script(
        'abtestkit-onboarding',
        'ABTESTKIT_ONBOARDING',
        array(
            'nonce' => wp_create_nonce( 'wp_rest' ),
            'rest'  => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
            'done'  => (bool) get_option( 'abtestkit_onboarding_done' ),
            'site'  => get_bloginfo( 'name' ),
            // Telemetry sending itself is a no-op, but this flag can still control UI if needed
            'telemetryOptedIn' => abtestkit_is_telemetry_opted_in(),
            'links' => array(
                'dashboard' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
                'createUrl' => admin_url( 'admin.php?page=abtestkit-pt-wizard' ),

                'new_post'  => admin_url( 'post-new.php' ),
                'blocks'    => admin_url( 'edit.php?post_type=page' ),
                'settings'  => admin_url( 'options-general.php?page=abtestkit-settings' ),
                'plugins'   => admin_url( 'plugins.php' ),
            ),
            // Image assets for the 7-step onboarding wizard
            'assets' => array(
                // Page 1
                'getStarted'  => plugins_url( 'assets/img/onboarding-get-started.png', __FILE__ ),

                // Page 2
                'selectTestType' => plugins_url( 'assets/img/onboarding-select-test-type.png', __FILE__ ),

                // Page 3 – Pages
                'pagesVersionBSource' => plugins_url( 'assets/img/onboarding-page-version-b-source.png', __FILE__ ),
                'pagesReviewVersions' => plugins_url( 'assets/img/onboarding-review-versions.png', __FILE__ ),
                'pagesReviewVersions2' => plugins_url( 'assets/img/onboarding-review-versions-2.png', __FILE__ ),

                // Page 4 – Products
                'productsSelectProduct'  => plugins_url( 'assets/img/onboarding-select-product.png', __FILE__ ),
                'productsReviewVersions' => plugins_url( 'assets/img/onboarding-product-review-versions.png', __FILE__ ),

                // Page 5 – Conversions & Clicks
                'conversionType'               => plugins_url( 'assets/img/onboarding-choose-conversion-type.png', __FILE__ ),
                'clicksSelectTarget'           => plugins_url( 'assets/img/onboarding-clicks-select-target-in-preview.png', __FILE__ ),
                'clicksClickTarget'            => plugins_url( 'assets/img/onboarding-clicks-click-a-target.png', __FILE__ ),
                'clicksSelectAnother'          => plugins_url( 'assets/img/onboarding-clicks-select-another.png', __FILE__ ),
                'clicksOnAnotherPage'          => plugins_url( 'assets/img/onboarding-clicks-on-another-page.png', __FILE__ ),
                'clicksSelectTargetDifferentPage' => plugins_url( 'assets/img/onboarding-select-target-different-page.png', __FILE__ ),

                // Page 6 – Summary
                'summary'  => plugins_url( 'assets/img/onboarding-summary.png', __FILE__ ),

                // Page 7 – Dashboard / How tests work
                'dashboard' => plugins_url( 'assets/img/onboarding-dashboard.png', __FILE__ ),
            ),
        )
    );
} );

// ───────────────────────────────────────────────────────────
// Minimal REST endpoint to mark onboarding complete
// (and to capture choices like telemetry opt-in)
// ───────────────────────────────────────────────────────────
add_action( 'rest_api_init', function () {
    register_rest_route(
        'abtestkit/v1',
        '/onboarding',
        array(
            array(
                'methods'             => 'POST',
                'permission_callback' => function () {
                    return current_user_can( 'manage_options' );
                },
                'callback'            => function ( WP_REST_Request $req ) {
                    $done      = (bool) $req->get_param( 'done' );
                    $telemetry = (bool) $req->get_param( 'telemetry' );

                    update_option( 'abtestkit_onboarding_done', $done ? '1' : '0' );

                    // Use the canonical helper/option so everything stays in sync
                    abtestkit_set_telemetry_optin( (bool) $telemetry );

                    return rest_ensure_response(
                        array(
                            'ok'        => true,
                            'done'      => $done,
                            'telemetry' => $telemetry,
                        )
                    );
                },
            ),
        )
    );

    // --- Page Test Wizard: search pages/products ---
    register_rest_route(
        'abtestkit/v1',
        '/pt/pages',
        [
            'methods'             => 'GET',
            'permission_callback' => function () { return current_user_can( 'manage_options' ); },
            'callback'            => function ( WP_REST_Request $req ) {
                $q    = sanitize_text_field( (string) $req->get_param( 'q' ) );
                $type = sanitize_key( (string) $req->get_param( 'type' ) );

                // Allow pages, posts or WooCommerce products; default to pages
                $post_type = in_array( $type, [ 'page', 'post', 'product' ], true ) ? $type : 'page';

                // If we're searching products, exclude any Version B posts used in tests
                $post__not_in = [];
                if ( $post_type === 'product' ) {
                    foreach ( abtestkit_pt_all() as $t ) {
                        if ( ! empty( $t['variant_id'] ) ) {
                            $post__not_in[] = (int) $t['variant_id'];
                        }
                    }
                    $post__not_in = array_values( array_unique( array_filter( $post__not_in ) ) );
                }

                $query_args = [
                    'post_type'           => $post_type,
                    'posts_per_page'      => 200,
                    'post_status'         => [ 'publish', 'draft', 'pending', 'future', 'private' ],
                    'no_found_rows'       => true,
                    'fields'              => 'ids',
                    'post__not_in'        => $post__not_in,
                    'ignore_sticky_posts' => true,
                    'orderby'             => 'date',
                    'order'               => 'DESC',
                ];

                // Only apply a search query when the user has actually typed one.
                // On some large/live sites, passing an empty `s` can produce odd results.
                if ( $q !== '' ) {
                    $query_args['s'] = $q;
                }

                $query = new WP_Query( $query_args );

                $items = [];
                foreach ( (array) $query->posts as $pid ) {
                    $actual_post_type = get_post_type( $pid );

                    // Hard guard against anything leaking into the results from filters/plugins.
                    if ( $actual_post_type !== $post_type ) {
                        continue;
                    }

                    $item = [
                        'id'        => (int) $pid,
                        'title'     => get_the_title( $pid ),
                        'status'    => get_post_status( $pid ),
                        'post_type' => (string) $actual_post_type,
                        // display-ready localised date (matches WP list tables)
                        'date'      => get_the_date( 'Y/m/d', $pid ),
                        // raw ISO for any future sorting/formatting if you want it
                        'date_iso'  => get_post_field( 'post_date', $pid ),
                    ];

                    // Check if this page/product is already part of a running test.
                    $conflicts = abtestkit_pt_conflicts_for_pages( (int) $pid, 0 );
                    $in_running_test = ! empty( $conflicts );

                    $item['in_running_test'] = $in_running_test;
                    $item['disabled']        = $in_running_test;
                    $item['disabled_reason'] = $in_running_test ? 'conflict_running' : '';
                    $item['conflicts']       = $conflicts;

                    // If listing WooCommerce products, include basic product meta
                    if ( $post_type === 'product' && function_exists( 'wc_get_product' ) ) {
                        $product = wc_get_product( $pid );
                        if ( $product ) {
                            $image_id    = $product->get_image_id();
                            $gallery_ids = $product->get_gallery_image_ids();

                            $image_url    = $image_id ? wp_get_attachment_image_url( $image_id, 'full' ) : '';
                            $gallery_urls = [];

                            if ( is_array( $gallery_ids ) && $gallery_ids ) {
                                foreach ( $gallery_ids as $gid ) {
                                    $url = wp_get_attachment_image_url( $gid, 'full' );
                                    if ( $url ) {
                                        $gallery_urls[] = $url;
                                    }
                                }
                            }

                            $item['product'] = [
                                'price'             => $product->get_price(),
                                'regular_price'     => $product->get_regular_price(),
                                'sale_price'        => $product->get_sale_price(),
                                'short_description' => $product->get_short_description(),
                                'description'       => $product->get_description(),
                                'image_id'          => $image_id,
                                'image_url'         => $image_url,
                                'gallery_ids'       => $gallery_ids,
                                'gallery_urls'      => $gallery_urls,
                            ];

                            // Also expose a simple Category label for the wizard list
                            $cats      = get_the_terms( $pid, 'product_cat' );
                            $cat_names = [];

                            if ( is_array( $cats ) ) {
                                foreach ( $cats as $cat ) {
                                    if ( isset( $cat->name ) && $cat->name !== '' ) {
                                        $cat_names[] = $cat->name;
                                    }
                                }
                            }
                            if ( $cat_names ) {
                                // Top-level field so JS can use p.category directly
                                $item['category'] = implode( ', ', $cat_names );
                            }
                        }
                    }
                    $items[] = $item;
                }

                return rest_ensure_response( [ 'ok' => true, 'pages' => $items ] );
            },
        ]
    );

// --- Page Test Wizard: create a temporary preview token for WooCommerce product overrides ---
register_rest_route(
    'abtestkit/v1',
    '/pt/product-preview',
    [
        'methods'             => 'POST',
        'permission_callback' => function () { return current_user_can( 'manage_options' ); },
        'callback'            => function ( WP_REST_Request $req ) {

            $control_id = absint( $req->get_param( 'control_id' ) );
            $overrides  = $req->get_param( 'product_overrides' );

            if ( $control_id <= 0 ) {
                return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
            }

            if ( ! is_array( $overrides ) ) {
                $overrides = [];
            }

            // Whitelist keys to store
            $allowed = [
                'title',
                'price',
                'regular_price',
                'sale_price',
                'short_description',
                'description',
                'image_url',
                'image_id',
                'gallery_urls',
                'gallery_ids',
            ];

            $clean = [];
            foreach ( $allowed as $k ) {
                if ( ! array_key_exists( $k, $overrides ) ) continue;

                $v = $overrides[ $k ];

                if ( is_array( $v ) ) {
                    // Arrays are only allowed for gallery urls/ids
                    if ( $k === 'gallery_urls' ) {
                        $urls = array_map( 'trim', array_map( 'sanitize_text_field', array_map( 'wp_unslash', $v ) ) );
                        $urls = array_values( array_filter( $urls ) );
                        if ( $urls ) $clean[ $k ] = $urls;
                    } elseif ( $k === 'gallery_ids' ) {
                        $ids = array_map( 'absint', $v );
                        $ids = array_values( array_filter( $ids ) );
                        if ( $ids ) $clean[ $k ] = $ids;
                    }
                    continue;
                }

                // Scalars
                $sv = sanitize_text_field( wp_unslash( (string) $v ) );
                if ( $sv !== '' ) {
                    $clean[ $k ] = $sv;
                }
            }

            // Must have at least one override to be meaningful
            if ( empty( $clean ) ) {
                return rest_ensure_response( [ 'ok' => false, 'error' => 'no_overrides' ] );
            }

            $token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : wp_hash( microtime( true ) . wp_rand() );
            $key   = 'abtestkit_product_preview_' . $token;

            // 10 minutes is plenty for a wizard preview
            set_transient( $key, [
                'control_id' => $control_id,
                'overrides'  => $clean,
                'user_id'    => get_current_user_id(),
                'created'    => time(),
            ], 10 * MINUTE_IN_SECONDS );

            return rest_ensure_response( [ 'ok' => true, 'token' => $token ] );
        },
    ]
);

// --- Page Test Wizard: create test (duplicate or use existing as Version B) ---
register_rest_route(
    'abtestkit/v1',
    '/pt/create',
    [
        'methods'             => 'POST',
        'permission_callback' => function () { return current_user_can( 'manage_options' ); },
        'callback'            => function ( WP_REST_Request $req ) {
            $control_id = absint( $req->get_param( 'control_id' ) );
            $test_title = sanitize_text_field( (string) $req->get_param( 'test_title' ) );
            $mode       = sanitize_key( $req->get_param( 'b_mode' ) ); // 'duplicate' | 'existing'
            $b_page_id  = absint( $req->get_param( 'b_page_id' ) );

            // Existing page as B: optional SEO protection toggle (default ON).
            $seo_safe_existing_b = $req->get_param( 'seo_safe_existing_b' );
            $seo_safe_existing_b = ( $seo_safe_existing_b === null ) ? true : (bool) $seo_safe_existing_b;

            $start      = (bool) $req->get_param( 'start' );
            $split      = max( 0, min( 100, (int) ( $req->get_param( 'split' ) ?? 50 ) ) );

            // Decision rule (wizard dropdown) — controls when a winner may be auto-declared.
            $decision_rule = sanitize_key( (string) $req->get_param( 'decision_rule' ) ); // fast|balanced|precise|manual
            $decision_mode = sanitize_key( (string) $req->get_param( 'decision_mode' ) ); // auto|manual

            $allowed_rules = [ 'fast', 'balanced', 'precise', 'manual' ];
            if ( ! in_array( $decision_rule, $allowed_rules, true ) ) {
                $decision_rule = 'balanced';
            }

            // Defaults
            $min_impressions = 50;
            $min_conversions = 5;
            $decision_mode   = ( $decision_mode === 'manual' || $decision_rule === 'manual' ) ? 'manual' : 'auto';

            if ( $decision_mode === 'manual' ) {
                // Manual mode: never auto-declare a winner.
                $min_impressions = 0;
                $min_conversions = 0;
                $decision_rule   = 'manual';
            } else {
                // Auto mode: accept explicit thresholds but enforce allowed values.
                $mi = absint( $req->get_param( 'min_impressions' ) );
                $mc = absint( $req->get_param( 'min_conversions' ) );

                $allowed_mi = [ 25, 50, 75 ];
                $allowed_mc = [ 3, 5, 10 ];

                $min_impressions = in_array( $mi, $allowed_mi, true ) ? $mi : 50;
                $min_conversions = in_array( $mc, $allowed_mc, true ) ? $mc : 5;

                // Ensure rule presets stay consistent even if JS changes later.
                if ( $decision_rule === 'fast' )    { $min_impressions = 25; $min_conversions = 3; }
                if ( $decision_rule === 'balanced' ){ $min_impressions = 50; $min_conversions = 5; }
                if ( $decision_rule === 'precise' ) { $min_impressions = 75; $min_conversions = 10; }
            }

            $post_type     = $control_id ? get_post_type( $control_id ) : '';
            $allowed_types = [ 'page', 'post', 'product' ];

            if ( ! $control_id || ! in_array( $post_type, $allowed_types, true ) ) {
                return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_control' ] );
            }

            // Shadow product approach: do NOT accept product_overrides from the wizard.
            // Variant B is edited directly on the shadow product, and parsing large payloads can OOM.
            $overrides = [];
            if ( $post_type === 'product' ) {
                // Intentionally ignore $req->get_param('product_overrides')
            }

            $variant_id = 0;

            // Wizard safety:
            // The wizard can call /pt/duplicate (create B for editing) and then /pt/create.
            // If we "duplicate" again here, you end up with two shadows and the test wired to the wrong one.
            $user_id = (int) get_current_user_id();

            // If the UI already has a B post ID, prefer using it even if b_mode still says "duplicate".
            if ( $b_page_id && get_post_type( $b_page_id ) === $post_type ) {
                $ok = true;

                // Product tests: only accept a valid shadow product of this control.
                if ( $post_type === 'product' ) {
                    $ok = abtestkit_is_shadow_product( $b_page_id )
                        && (int) get_post_meta( $b_page_id, '_abtestkit_shadow_of', true ) === (int) $control_id;
                }

                if ( $ok && ! abtestkit_pt_variant_id_in_any_test( (int) $b_page_id ) ) {
                    $variant_id = (int) $b_page_id;
                }
            }

            if ( ! $variant_id ) {
                if ( $mode === 'duplicate' ) {

                    // Reuse the last duplicate created in this wizard session (if any).
                    $maybe = abtestkit_pt_get_last_duplicate_for_user( (int) $control_id, (int) $user_id );
                    if ( $maybe ) {
                        $variant_id = (int) $maybe;
                    } else {
                        $variant_id = abtestkit_duplicate_post_deep( $control_id );
                    }

                } elseif ( $mode === 'existing' && $b_page_id && get_post_type( $b_page_id ) === $post_type ) {

                    // Existing mode: for products, only allow a valid shadow of this control.
                    if ( $post_type === 'product' ) {
                        if ( abtestkit_is_shadow_product( $b_page_id )
                            && (int) get_post_meta( $b_page_id, '_abtestkit_shadow_of', true ) === (int) $control_id
                            && ! abtestkit_pt_variant_id_in_any_test( (int) $b_page_id )
                        ) {
                            $variant_id = (int) $b_page_id;
                        } else {
                            return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_mode' ] );
                        }
                    } else {
                        $variant_id = $b_page_id;
                    }

                } else {
                    return rest_ensure_response( [ 'ok' => false, 'error' => 'invalid_mode' ] );
                }
            }

            // For all tests (including products now), we require a real variant ID.
            if ( ! $variant_id ) {
                return rest_ensure_response( [ 'ok' => false, 'error' => 'create_failed' ] );
            }

            // For non-product tests we still require a real B page.
            if ( $post_type !== 'product' && ! $variant_id ) {
                return rest_ensure_response( [ 'ok' => false, 'error' => 'create_failed' ] );
            }

            $test = [
                'id'              => 'pt-' . substr( md5( $control_id . '|' . microtime( true ) ), 0, 8 ),
                'title'           => $test_title !== '' ? $test_title : get_the_title( $control_id ),
                'control_id'      => $control_id,
                // Product tests use a virtual B (no separate post); page tests keep a real B.
                'variant_id'      => $variant_id,
                'status'          => $start ? 'running' : 'draft',
                'split'           => $split,
                'decision_rule'   => $decision_rule,
                'decision_mode'   => $decision_mode,
                'min_impressions' => (int) $min_impressions,
                'min_conversions' => (int) $min_conversions,
                'cookie_ttl_days' => 30,
                'started_at'      => $start ? time() : 0,
                'finished_at'     => 0,
                'paused_at'       => 0,
                'paused_total'    => 0,
                // Mark what kind of test this is
                'kind'            => ( $post_type === 'product' )
                    ? 'product'
                    : ( $post_type === 'post' ? 'post' : 'page' ),
            ];

            // Attach overrides for product tests so Woo filters can read them.
            if ( $post_type === 'product' ) {
                $test['overrides'] = $overrides;
            }

            // Prevent creating a test if either page/product is already part of another running test.
            // This applies when creating both drafts (paused) and running tests.
            $conflicts = abtestkit_pt_conflicts_for_pages(
                (int) $test['control_id'],
                (int) $test['variant_id']
            );

            if ( ! empty( $conflicts ) ) {
                return rest_ensure_response(
                    [
                        'ok'    => false,
                        'error' => 'conflict_running',
                        'info'  => [
                            'message'   => 'This page is already in a running test.',
                            'conflicts' => $conflicts,
                        ],
                    ]
                );
            }

            // store wizard-picked goal info
            // Goal can be: 'clicks' | 'form' | 'add_to_cart' | 'purchase'
            $goal  = sanitize_key( $req->get_param( 'goal' ) );
            $links = array_filter(
                array_map(
                    'sanitize_text_field',
                    (array) $req->get_param( 'links' )
                )
            );

            // Back-compat: normalise any old values to the new shape
            if ( $goal === 'button' || $goal === 'link' ) {
                $goal = 'clicks';
            }

            if ( in_array( $goal, [ 'clicks', 'form', 'add_to_cart', 'purchase' ], true ) ) {
                $test['goal'] = $goal;

                // Only click-based goals use explicit targets.
                if ( in_array( $goal, [ 'clicks', 'add_to_cart' ], true ) ) {
                    $test['links'] = $links;
                } else {
                    $test['links'] = [];
                }
            }

            // If using an EXISTING page/post as Version B, mark it as SEO-protected while this test exists
            // (even if paused/draft). Shadow duplicates are permanently protected already.
            if ( $post_type !== 'product' && $mode === 'existing' && $seo_safe_existing_b ) {
                if ( ! abtestkit_is_shadow_variant( (int) $variant_id ) ) {
                    abtestkit_pt_mark_existing_variant_in_use(
                        (int) $variant_id,
                        (int) $control_id,
                        (string) $test['id']
                    );
                }
            }

            abtestkit_pt_put( $test );

                        // Telemetry (opt-in): successful test creation
                        if ( function_exists( 'abtestkit_telemetry_track_test_created' ) ) {
                            abtestkit_telemetry_track_test_created( $test, [
                                'b_mode'              => (string) $mode,
                                'seo_safe_existing_b' => $seo_safe_existing_b ? 1 : 0,
                            ] );
                        }

                        // Clear any cached duplicate pointer for this user+control once the test is created.
            abtestkit_pt_clear_last_duplicate_for_user( (int) $control_id, (int) get_current_user_id() );

            // Once the user has created a test, never show onboarding again.
            update_option( 'abtestkit_onboarding_done', '1' );

            return rest_ensure_response(
                [
                    'ok'       => true,
                    'test'     => $test,
                    'redirect' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
                ]
            );
        },
    ]
);

    // --- Dashboard: list all tests with basic stats ---
    register_rest_route(
        'abtestkit/v1',
        '/pt',
        [
            'methods'             => 'GET',
            'permission_callback' => function () {
                return current_user_can( 'manage_options' );
            },
            'callback'            => function ( WP_REST_Request $req ) {
                $tests = abtestkit_pt_all();
                $tests = is_array( $tests ) ? $tests : [];

                $dashboard_cache_key = abtestkit_pt_dashboard_cache_key( $tests );
                $cached_dashboard    = get_transient( $dashboard_cache_key );

                if ( is_array( $cached_dashboard ) ) {
                    return rest_ensure_response( $cached_dashboard );
                }

                $out        = [];
                $stats_bulk = abtestkit_pt_stats_bulk( $tests );

                foreach ( $tests as $test ) {
                    $test_id    = isset( $test['id'] ) ? (string) $test['id'] : '';
                    $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
                    $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
                    $kind       = isset( $test['kind'] ) ? (string) $test['kind'] : '';

                    if ( $kind === '' ) {
                        $control_type = $control_id > 0 ? get_post_type( $control_id ) : '';

                        if ( $control_type === 'product' ) {
                            $kind = 'product';
                        } elseif ( $control_type === 'post' ) {
                            $kind = 'post';
                        } else {
                            $kind = 'page';
                        }
                    }

                    // Base admin previews are different for product tests vs page/post tests.
                    // For products, we keep the same URL and force A/B via query args.
                    // For pages/posts, A and B each have their own permalink.
                    // Normal URL (non-preview) for the dashboard title link:
                    $url       = '';
                    $preview_a = '';
                    $preview_b = '';

                    if ( $control_id > 0 ) {
                        $base = get_permalink( $control_id );
                        $url  = $base;

                        if ( $kind === 'product' ) {
                            // Product tests: force the variant via query string, but still include
                            // abtestkit_preview=1 so no impressions/clicks are logged.
                            $preview_a = add_query_arg(
                                [
                                    'abtestkit_preview' => '1',
                                    'abtestkit_force'   => 'A',
                                ],
                                $base
                            );

                            $preview_b = add_query_arg(
                                [
                                    'abtestkit_preview' => '1',
                                    'abtestkit_force'   => 'B',
                                ],
                                $base
                            );
                        } else {
                            // Page/post tests: previews point at the underlying permalinks.
                            $preview_a = add_query_arg( 'abtestkit_preview', '1', $base );

                            if ( $variant_id > 0 ) {
                                $preview_b = add_query_arg(
                                    'abtestkit_preview',
                                    '1',
                                    get_permalink( $variant_id )
                                );
                            }
                        }
                    }

                    $out[] = [
                        'id'              => isset( $test['id'] ) ? (string) $test['id'] : '',
                        'name'            => isset( $test['title'] ) ? (string) $test['title'] : '',
                        'kind'            => $kind,
                        'status'          => isset( $test['status'] ) ? (string) $test['status'] : 'paused',
                        'decision_rule'   => isset( $test['decision_rule'] ) ? (string) $test['decision_rule'] : 'balanced',
                        'decision_mode'   => isset( $test['decision_mode'] ) ? (string) $test['decision_mode'] : 'auto',
                        'min_impressions' => isset( $test['min_impressions'] ) ? (int) $test['min_impressions'] : 50,
                        'min_conversions' => isset( $test['min_conversions'] ) ? (int) $test['min_conversions'] : 5,
                        'finished_at'     => isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0,
                        'winner'          => abtestkit_pt_winner_for_dashboard( $test ),
                        'goal'            => isset( $test['goal'] ) ? (string) $test['goal'] : '',
                        'stats'           => $stats_bulk[ $test_id ] ?? abtestkit_pt_stats_default(),
                        'url'             => $url,
                        'performance_url' => admin_url(
                            'admin.php?page=abtestkit-test-performance'
                        ) . '#' . rawurlencode( (string) $test_id ),
                        // Legacy: keep preview_url pointing at Version A (Version A permalink or forced A for products)
                        'preview_url'     => $preview_a,
                        // New: explicit admin preview URLs for A/B
                        'preview_a'       => $preview_a,
                        'preview_b'       => $preview_b,
                    ];
                }

                set_transient( $dashboard_cache_key, $out, MINUTE_IN_SECONDS );
                return rest_ensure_response( $out );
            },
        ]
    );

    // --- Test performance: update test title ---
    register_rest_route(
        'abtestkit/v1',
        '/pt/update-title',
        [
            'methods'             => 'POST',
            'permission_callback' => function () {
                return current_user_can( 'manage_options' );
            },
            'callback'            => function ( WP_REST_Request $req ) {

                $test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
                $title   = sanitize_text_field( (string) $req->get_param( 'title' ) );

                if ( $test_id === '' ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'missing_test_id',
                        ]
                    );
                }

                if ( $title === '' ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'missing_title',
                        ]
                    );
                }

                $test = abtestkit_pt_get( $test_id );
                if ( ! is_array( $test ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'not_found',
                        ]
                    );
                }

                $test['title'] = $title;
                abtestkit_pt_put( $test );

                return rest_ensure_response(
                    [
                        'ok'    => true,
                        'title' => $title,
                        'test'  => [
                            'id'    => (string) ( $test['id'] ?? '' ),
                            'title' => (string) ( $test['title'] ?? '' ),
                        ],
                    ]
                );
            },
        ]
    );

    // --- Test performance: single test details + timeline ---
    register_rest_route(
        'abtestkit/v1',
        '/pt/performance',
        [
            'methods'             => 'POST',
            'permission_callback' => function () {
                return current_user_can( 'manage_options' );
            },
            'callback'            => function ( WP_REST_Request $req ) {
                global $wpdb;

                $test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
                $range   = sanitize_key( (string) $req->get_param( 'range' ) );

                if ( ! in_array( $range, [ 'day', 'week', 'month' ], true ) ) {
                    $range = 'day';
                }

                if ( $test_id === '' ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'missing_test_id',
                        ]
                    );
                }

                $test = abtestkit_pt_get( $test_id );
                if ( ! is_array( $test ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'not_found',
                        ]
                    );
                }

                $cache_key = abtestkit_pt_performance_cache_key( $test_id, $range );
                $cached    = get_transient( $cache_key );

                if ( is_array( $cached ) && ! empty( $cached['ok'] ) && ! empty( $cached['test'] ) ) {
                    return rest_ensure_response( $cached );
                }

                $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
                $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
                $kind       = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
                $url        = $control_id > 0 ? get_permalink( $control_id ) : '';

                $preview_a = '';
                $preview_b = '';

                if ( $control_id > 0 ) {
                    $base = get_permalink( $control_id );

                    if ( $kind === 'product' ) {
                        $preview_a = add_query_arg(
                            [
                                'abtestkit_preview' => '1',
                                'abtestkit_force'   => 'A',
                            ],
                            $base
                        );

                        $preview_b = add_query_arg(
                            [
                                'abtestkit_preview' => '1',
                                'abtestkit_force'   => 'B',
                            ],
                            $base
                        );
                    } else {
                        $preview_a = add_query_arg( 'abtestkit_preview', '1', $base );

                        if ( $variant_id > 0 ) {
                            $preview_b = add_query_arg(
                                'abtestkit_preview',
                                '1',
                                get_permalink( $variant_id )
                            );
                        }
                    }
                }

                $timeline = [];
                $http_excluded_count = 0;
                $http_excluded_last  = '';

                if ( $test_id !== '' ) {
                    $table = ABTESTKIT_EVENTS_TABLE;

                    // Keep timeline queries bounded on slow sites.
                    $days_back = abtestkit_pt_timeline_days_back( $range );
                    $from_date = gmdate( 'Y-m-d 00:00:00', strtotime( '-' . $days_back . ' days', current_time( 'timestamp' ) ) );

                    if ( abtestkit_events_table_has_column( 'amount' ) ) {
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $rows = $wpdb->get_results(
                            $wpdb->prepare(
                                "
                                SELECT
                                    DATE(`time`) AS bucket,
                                    variant,
                                    event_type,
                                    protocol,
                                    excluded_reason,
                                    COUNT(*) AS total,
                                    COALESCE(SUM(amount), 0) AS revenue,
                                    MAX(`time`) AS last_seen
                                FROM %i
                                WHERE ab_test_id = %s
                                  AND (
                                        event_type IN ('impression', 'click', 'purchase')
                                        OR (
                                            event_type = 'protocol_warning'
                                            AND protocol = 'http'
                                            AND excluded_reason = 'http'
                                        )
                                  )
                                  AND `time` >= %s
                                GROUP BY bucket, variant, event_type, protocol, excluded_reason
                                ORDER BY bucket ASC
                                ",
                                $table,
                                $test_id,
                                $from_date
                            ),
                            ARRAY_A
                        );
                    } else {
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $rows = $wpdb->get_results(
                            $wpdb->prepare(
                                "
                                SELECT
                                    DATE(`time`) AS bucket,
                                    variant,
                                    event_type,
                                    protocol,
                                    excluded_reason,
                                    COUNT(*) AS total,
                                    0 AS revenue,
                                    MAX(`time`) AS last_seen
                                FROM %i
                                WHERE ab_test_id = %s
                                  AND (
                                        event_type IN ('impression', 'click', 'purchase')
                                        OR (
                                            event_type = 'protocol_warning'
                                            AND protocol = 'http'
                                            AND excluded_reason = 'http'
                                        )
                                  )
                                  AND `time` >= %s
                                GROUP BY bucket, variant, event_type, protocol, excluded_reason
                                ORDER BY bucket ASC
                                ",
                                $table,
                                $test_id,
                                $from_date
                            ),
                            ARRAY_A
                        );
                    }

                    $daily_indexed = [];

                    foreach ( (array) $rows as $row ) {
                        $bucket          = isset( $row['bucket'] ) ? (string) $row['bucket'] : '';
                        $variant         = isset( $row['variant'] ) ? (string) $row['variant'] : '';
                        $event_type      = isset( $row['event_type'] ) ? (string) $row['event_type'] : '';
                        $protocol        = isset( $row['protocol'] ) ? (string) $row['protocol'] : '';
                        $excluded_reason = isset( $row['excluded_reason'] ) ? (string) $row['excluded_reason'] : '';
                        $total           = isset( $row['total'] ) ? (int) $row['total'] : 0;
                        $revenue         = isset( $row['revenue'] ) ? (float) $row['revenue'] : 0.0;
                        $last_seen       = isset( $row['last_seen'] ) ? (string) $row['last_seen'] : '';

                        if ( $event_type === 'protocol_warning' && $protocol === 'http' && $excluded_reason === 'http' ) {
                            $http_excluded_count += $total;

                            if ( $last_seen !== '' && ( $http_excluded_last === '' || strtotime( $last_seen ) > strtotime( $http_excluded_last ) ) ) {
                                $http_excluded_last = $last_seen;
                            }

                            continue;
                        }

                        if ( $bucket === '' || ! in_array( $variant, [ 'A', 'B' ], true ) ) {
                            continue;
                        }

                        if ( ! isset( $daily_indexed[ $bucket ] ) ) {
                            $daily_indexed[ $bucket ] = [
                                'bucket' => $bucket,
                                'A'      => [
                                    'impressions' => 0,
                                    'clicks'      => 0,
                                    'purchases'   => 0,
                                    'revenue'     => 0.0,
                                ],
                                'B'      => [
                                    'impressions' => 0,
                                    'clicks'      => 0,
                                    'purchases'   => 0,
                                    'revenue'     => 0.0,
                                ],
                            ];
                        }

                        if ( $event_type === 'impression' ) {
                            $daily_indexed[ $bucket ][ $variant ]['impressions'] = $total;
                        } elseif ( $event_type === 'click' ) {
                            $daily_indexed[ $bucket ][ $variant ]['clicks'] = $total;
                        } elseif ( $event_type === 'purchase' ) {
                            $daily_indexed[ $bucket ][ $variant ]['purchases'] = $total;
                            $daily_indexed[ $bucket ][ $variant ]['revenue']   = round( $revenue, 2 );
                        }
                    }

                    $start_ts = isset( $test['started_at'] ) ? (int) $test['started_at'] : 0;
                    $end_ts   = isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0;

                    $first_bucket = array_key_first( $daily_indexed );
                    if ( $first_bucket ) {
                        $first_bucket_ts = strtotime( $first_bucket . ' 00:00:00' );

                        // Always include the earliest recorded event bucket in the chart,
                        // even if started_at was previously shifted forward by pause/resume.
                        if ( $start_ts <= 0 || ( $first_bucket_ts && $first_bucket_ts < $start_ts ) ) {
                            $start_ts = $first_bucket_ts;
                        }
                    }

                    if ( $end_ts <= 0 ) {
                        $end_ts = current_time( 'timestamp' );
                    }

                    $daily_timeline = [];

                    if ( $start_ts > 0 && $end_ts > 0 && $end_ts >= $start_ts ) {
                        $cursor = strtotime( gmdate( 'Y-m-d 00:00:00', $start_ts ) );
                        $end_day = strtotime( gmdate( 'Y-m-d 00:00:00', $end_ts ) );

                        while ( $cursor <= $end_day ) {
                            $bucket = gmdate( 'Y-m-d', $cursor );

                            $daily_timeline[] = isset( $daily_indexed[ $bucket ] )
                                ? $daily_indexed[ $bucket ]
                                : [
                                    'bucket' => $bucket,
                                    'A'      => [
                                        'impressions' => 0,
                                        'clicks'      => 0,
                                        'purchases'   => 0,
                                        'revenue'     => 0.0,
                                    ],
                                    'B'      => [
                                        'impressions' => 0,
                                        'clicks'      => 0,
                                        'purchases'   => 0,
                                        'revenue'     => 0.0,
                                    ],
                                ];

                            $cursor = strtotime( '+1 day', $cursor );
                        }
                    } else {
                        $daily_timeline = array_values( $daily_indexed );
                    }

                    if ( $range === 'day' ) {
                        $timeline = $daily_timeline;
                    } else {
                        $rolled = [];

                        foreach ( $daily_timeline as $day_row ) {
                            $day_bucket = isset( $day_row['bucket'] ) ? (string) $day_row['bucket'] : '';
                            if ( $day_bucket === '' ) {
                                continue;
                            }

                            $day_ts = strtotime( $day_bucket . ' 00:00:00' );
                            if ( false === $day_ts ) {
                                continue;
                            }

                            if ( $range === 'month' ) {
                                $rollup_bucket = gmdate( 'Y-m-01', $day_ts );
                            } else {
                                $weekday       = (int) gmdate( 'N', $day_ts );
                                $rollup_bucket = gmdate( 'Y-m-d', strtotime( '-' . ( $weekday - 1 ) . ' days', $day_ts ) );
                            }

                            if ( ! isset( $rolled[ $rollup_bucket ] ) ) {
                                $rolled[ $rollup_bucket ] = [
                                    'bucket' => $rollup_bucket,
                                    'A'      => [
                                        'impressions' => 0,
                                        'clicks'      => 0,
                                        'purchases'   => 0,
                                        'revenue'     => 0.0,
                                    ],
                                    'B'      => [
                                        'impressions' => 0,
                                        'clicks'      => 0,
                                        'purchases'   => 0,
                                        'revenue'     => 0.0,
                                    ],
                                ];
                            }

                            $rolled[ $rollup_bucket ]['A']['impressions'] += isset( $day_row['A']['impressions'] ) ? (int) $day_row['A']['impressions'] : 0;
                            $rolled[ $rollup_bucket ]['A']['clicks']      += isset( $day_row['A']['clicks'] ) ? (int) $day_row['A']['clicks'] : 0;
                            $rolled[ $rollup_bucket ]['A']['purchases']   += isset( $day_row['A']['purchases'] ) ? (int) $day_row['A']['purchases'] : 0;
                            $rolled[ $rollup_bucket ]['A']['revenue']     += isset( $day_row['A']['revenue'] ) ? (float) $day_row['A']['revenue'] : 0.0;

                            $rolled[ $rollup_bucket ]['B']['impressions'] += isset( $day_row['B']['impressions'] ) ? (int) $day_row['B']['impressions'] : 0;
                            $rolled[ $rollup_bucket ]['B']['clicks']      += isset( $day_row['B']['clicks'] ) ? (int) $day_row['B']['clicks'] : 0;
                            $rolled[ $rollup_bucket ]['B']['purchases']   += isset( $day_row['B']['purchases'] ) ? (int) $day_row['B']['purchases'] : 0;
                            $rolled[ $rollup_bucket ]['B']['revenue']     += isset( $day_row['B']['revenue'] ) ? (float) $day_row['B']['revenue'] : 0.0;
                        }

                        foreach ( $rolled as $bucket_key => $bucket_row ) {
                            $rolled[ $bucket_key ]['A']['revenue'] = round( (float) $bucket_row['A']['revenue'], 2 );
                            $rolled[ $bucket_key ]['B']['revenue'] = round( (float) $bucket_row['B']['revenue'], 2 );
                        }

                        ksort( $rolled );
                        $timeline = array_values( $rolled );
                    }
                }

                if ( ! empty( $wpdb->last_error ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'db_error',
                            'debug' => $wpdb->last_error,
                        ]
                    );
                }

                $stats = abtestkit_pt_stats( $test );

                if ( ! empty( $wpdb->last_error ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'db_error',
                            'debug' => $wpdb->last_error,
                        ]
                    );
                }

                $goal = '';
                if ( isset( $test['goal'] ) ) {
                    $goal = sanitize_text_field( (string) $test['goal'] );
                }

                $response = [
                    'ok' => true,
                    'test' => [
                        'id'                  => isset( $test['id'] ) ? (string) $test['id'] : '',
                        'title'               => isset( $test['title'] ) ? (string) $test['title'] : '',
                        'kind'                => $kind,
                        'status'              => isset( $test['status'] ) ? (string) $test['status'] : 'paused',
                        'goal'                => $goal,
                        'decision_rule'       => isset( $test['decision_rule'] ) ? (string) $test['decision_rule'] : 'balanced',
                        'decision_mode'       => isset( $test['decision_mode'] ) ? (string) $test['decision_mode'] : 'auto',
                        'min_impressions'     => isset( $test['min_impressions'] ) ? (int) $test['min_impressions'] : 50,
                        'min_conversions'     => isset( $test['min_conversions'] ) ? (int) $test['min_conversions'] : 5,
                        'started_at'          => isset( $test['started_at'] ) ? (int) $test['started_at'] : 0,
                        'finished_at'         => isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0,
                        'winner'              => isset( $test['winner'] ) ? (string) $test['winner'] : '',
                        'url'                 => $url ? (string) $url : '',
                        'preview_a'           => $preview_a ? (string) $preview_a : '',
                        'preview_b'           => $preview_b ? (string) $preview_b : '',
                        'control_id'          => $control_id,
                        'variant_id'          => $variant_id,
                        'stats'               => $stats,
                        'timeline'            => $timeline,
                        'http_excluded_count' => $http_excluded_count,
                        'http_excluded_last'  => $http_excluded_last,
                    ],
                ];

                set_transient(
                    $cache_key,
                    $response,
                    abtestkit_pt_performance_cache_ttl( $range )
                );

                return rest_ensure_response( $response );
            },
        ]
    );

    // --- Test performance: reset a whole test's metrics ---
    register_rest_route(
        'abtestkit/v1',
        '/pt/reset-test',
        [
            'methods'             => 'POST',
            'permission_callback' => function () {
                return current_user_can( 'manage_options' );
            },
            'callback'            => function ( WP_REST_Request $req ) {
                global $wpdb;

                $test_id = sanitize_text_field( (string) $req->get_param( 'test_id' ) );
                if ( $test_id === '' ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'missing_test_id',
                        ]
                    );
                }

                $test = abtestkit_pt_get( $test_id );
                if ( ! is_array( $test ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'not_found',
                        ]
                    );
                }

                $table = ABTESTKIT_EVENTS_TABLE;

                // Delete by test ID only so reset clears the same data source used by cards and timeline.
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $deleted = $wpdb->query(
                    $wpdb->prepare(
                        "DELETE FROM %i
                         WHERE ab_test_id = %s",
                        $table,
                        $test_id
                    )
                );

                $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
                $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
                $kind       = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';

                $post_ids = [ $control_id ];
                if ( $kind !== 'product' && $variant_id > 0 ) {
                    $post_ids[] = $variant_id;
                }
                $post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) );

                foreach ( $post_ids as $pid ) {
                    wp_cache_delete( "stats:$pid:$test_id", 'abtestkit_stats' );
                    wp_cache_delete( "stats_multi:$pid", 'abtestkit_stats' );
                }

                return rest_ensure_response(
                    [
                        'ok'      => true,
                        'deleted' => (int) $deleted,
                    ]
                );
            },
        ]
    );

    // --- Page Test Wizard: duplicate Version A now (returns the new page so you can edit it) ---
    register_rest_route(
        'abtestkit/v1',
        '/pt/duplicate',
        [
            'methods'             => 'POST',
            'permission_callback' => function () {
                return current_user_can( 'manage_options' );
            },
            'callback'            => function ( WP_REST_Request $req ) {
                $control_id    = absint( $req->get_param( 'control_id' ) );
                $post_type     = $control_id ? get_post_type( $control_id ) : '';
                $allowed_types = [ 'page', 'post', 'product' ];

                if ( ! $control_id || ! in_array( $post_type, $allowed_types, true ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'invalid_control',
                        ]
                    );
                }

                // Block creating a Version B draft if this page/product is already in a running test.
                $conflicts = abtestkit_pt_conflicts_for_pages( (int) $control_id, 0 );
                if ( ! empty( $conflicts ) ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'conflict_running',
                            'info'  => [
                                'message'   => 'This page is already in a running test.',
                                'conflicts' => $conflicts,
                            ],
                        ]
                    );
                }

                $user_id = (int) get_current_user_id();

                // If this user already duplicated this control in the current wizard session,
                // return the same B post instead of creating another clone.
                $variant_id = abtestkit_pt_get_last_duplicate_for_user( (int) $control_id, (int) $user_id );

                if ( ! $variant_id ) {
                    $variant_id = abtestkit_duplicate_post_deep( $control_id );
                    if ( $variant_id ) {
                        abtestkit_pt_set_last_duplicate_for_user( (int) $control_id, (int) $variant_id, (int) $user_id );
                    }
                }

                if ( ! $variant_id ) {
                    return rest_ensure_response(
                        [
                            'ok'    => false,
                            'error' => 'duplicate_failed',
                        ]
                    );
                }

                $page = [
                    'id'       => (int) $variant_id,
                    'title'    => get_the_title( $variant_id ),
                    'status'   => get_post_status( $variant_id ),
                    'date'     => get_the_date( 'Y/m/d', $variant_id ),
                    'date_iso' => get_post_field( 'post_date', $variant_id ),
                ];

                return rest_ensure_response(
                    [
                        'ok'   => true,
                        'page' => $page,
                    ]
                );
            },
        ]
    );
} );


/**
 * Load block-config.json into a PHP array for localization.
 */
function abtestkit_load_block_config() {
    // Make sure block-config.json lives under assets/js/, next to editor.js & ab-sidebar.js
    $config_path = plugin_dir_path( __FILE__ ) . 'assets/js/block-config.json';
    if ( ! file_exists( $config_path ) ) {
        return [];
    }
    $json = file_get_contents( $config_path );
    $arr  = json_decode( $json, true );
    if ( json_last_error() !== JSON_ERROR_NONE ) {
        return [];
    }
    return $arr;
}

function abtestkit_make_sig( int $post_id, int $ts ): string {
    return hash_hmac('sha256', $post_id . '|' . $ts, wp_salt('auth'));
}

function abtestkit_sanitize_test_id( $id ): string {
    return preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $id);
}

function abtestkit_rest_check_nonce(WP_REST_Request $request) {
    $nonce = $request->get_header('X-WP-Nonce');
    if (!$nonce) return false;
    return wp_verify_nonce($nonce, 'wp_rest') === 1;
}

function abtestkit_rest_permission( WP_REST_Request $request ) {
    return abtestkit_rest_check_nonce( $request ) && current_user_can( 'manage_options' );
}

/**
 * Register REST endpoints: /track, /stats, /evaluate, /reset.
 */
add_action('rest_api_init', function() {
    // Tracking endpoint (anonymous allowed; security handled inside handler)
    register_rest_route('abtestkit/v1', '/track', [
        'methods'             => 'POST',
        'permission_callback' => '__return_true',
        'callback'            => 'abtestkit_handle_track',
    ]);

    // Secure endpoints (require nonce+capability)
    foreach (['stats', 'evaluate', 'reset'] as $route) {
        register_rest_route('abtestkit/v1', "/$route", [
            'methods'             => $route === 'reset' ? 'POST' : 'GET',
            'permission_callback' => 'abtestkit_rest_permission',
            'callback'            => "abtestkit_handle_$route",
        ]);
    }

    // Capture delete reason from the Installed Plugins screen before WP deletes the plugin.
    register_rest_route('abtestkit/v1', '/delete-reason', [
        'methods'             => 'POST',
        'permission_callback' => function( WP_REST_Request $request ) {
            return abtestkit_rest_check_nonce( $request ) && current_user_can( 'activate_plugins' );
        },
        'callback'            => function( WP_REST_Request $request ) {
            $reason = sanitize_key( (string) $request->get_param( 'reason' ) );
            $detail = sanitize_text_field( (string) $request->get_param( 'detail' ) );

            abtestkit_telemetry_track_plugin_delete_reason( $reason, $detail );

            return rest_ensure_response( [ 'ok' => true ] );
        },
    ]);


    // Admin UI telemetry (wizard friction + existing milestones)
    // - opt-in gated by abtestkit_send_telemetry()
    // - tight allow-list + shallow payload sanitation
    register_rest_route( 'abtestkit/v1', '/telemetry', [
        'methods'             => 'POST',
        'permission_callback' => 'abtestkit_rest_permission',
        'callback'            => function( WP_REST_Request $req ) {

            $event   = sanitize_key( (string) $req->get_param( 'event' ) );
            $payload = $req->get_param( 'payload' );
            $payload = is_array( $payload ) ? $payload : [];

            // Tight allow-list: add new events here only.
            $allowed = [
                // Existing (editor milestones)
                'first_toggle_enabled',
                'first_test_launched',
                'first_test_finished',
                'winner_applied',

                // Wizard milestones (one-shot handled by helper)
                'pt_wizard_opened',

                // Wizard friction (session-based)
                'pt_wizard_session_start',
                'pt_wizard_step',
                'pt_wizard_blocked',
                'pt_wizard_action',
                'pt_wizard_create_attempt',
                'pt_wizard_create_failed',
                'pt_wizard_create_succeeded',
                'pt_wizard_result',
            ];

            if ( ! $event || ! in_array( $event, $allowed, true ) ) {
                return rest_ensure_response( [ 'ok' => false, 'error' => 'unknown_event' ] );
            }

            // Shallow sanitize (keep payload small + inert)
            $san      = [];
            $max_keys = 25;
            $i        = 0;

            foreach ( $payload as $k => $v ) {
                if ( $i++ >= $max_keys ) {
                    break;
                }

                $kk = sanitize_key( (string) $k );
                if ( $kk === '' ) {
                    continue;
                }

                if ( is_scalar( $v ) || $v === null ) {
                    $san[ $kk ] = is_string( $v ) ? sanitize_text_field( (string) $v ) : $v;
                    continue;
                }

                if ( is_array( $v ) ) {
                    $tmp = [];
                    foreach ( array_values( $v ) as $idx => $item ) {
                        if ( $idx >= 10 ) {
                            break;
                        }
                        if ( is_scalar( $item ) || $item === null ) {
                            $tmp[] = is_string( $item ) ? sanitize_text_field( (string) $item ) : $item;
                        }
                    }
                    $san[ $kk ] = $tmp;
                }
            }

            // Special: wizard opened milestone is already implemented as a one-shot helper.
            if ( $event === 'pt_wizard_opened' ) {
                if ( function_exists( 'abtestkit_telemetry_track_pt_wizard_opened' ) ) {
                    abtestkit_telemetry_track_pt_wizard_opened();
                }
                return rest_ensure_response( [ 'ok' => true ] );
            }

            // One-shot gating for editor milestones
            $oneshot = [
                'first_toggle_enabled' => 'first_toggle_enabled',
                'first_test_launched'  => 'first_test_launched',
                'first_test_finished'  => 'first_test_finished',
            ];

            if ( isset( $oneshot[ $event ] ) ) {
                $flag = (string) $oneshot[ $event ];
                if ( ! abtestkit_flag_is_set( $flag ) ) {
                    abtestkit_send_telemetry( $event, $san );
                    abtestkit_mark_flag( $flag );
                }
                return rest_ensure_response( [ 'ok' => true ] );
            }

            // Everything else: send every time (still opt-in gated inside abtestkit_send_telemetry()).
            abtestkit_send_telemetry( $event, $san );

            return rest_ensure_response( [ 'ok' => true ] );
        },
    ] );
});

/**
 * One-off upgrade: remove " (Version B)" suffix from any existing titles created by
 * older versions of the plugin that appended this to duplicated pages/posts/products.
 */
function abtestkit_upgrade_strip_version_b_titles() {
    // Only run once.
    if ( get_option( 'abtestkit_fixed_version_b_titles' ) ) {
        return;
    }

    $suffix     = ' (Version B)';
    $post_types = [ 'page', 'post', 'product' ];

    $args = [
        'post_type'      => $post_types,
        'post_status'    => 'any',
        'posts_per_page' => -1,
        's'              => $suffix, // candidate filter; we still double-check in PHP.
        'fields'         => 'ids',
    ];

    $query = new WP_Query( $args );

    if ( $query->have_posts() ) {
        foreach ( $query->posts as $post_id ) {
            $title = get_the_title( $post_id );
            if ( substr( $title, -strlen( $suffix ) ) === $suffix ) {
                $clean = substr( $title, 0, -strlen( $suffix ) );
                wp_update_post( [
                    'ID'         => $post_id,
                    'post_title' => $clean,
                ] );
            }
        }
    }

    update_option( 'abtestkit_fixed_version_b_titles', 1 );
}

add_action( 'admin_init', 'abtestkit_upgrade_strip_version_b_titles' );

function abtestkit_log_event_to_db( $type, $post_id, $ab_test_id, $variant, array $extra = [] ) {
    global $wpdb;

    // keep IDs tight and types sane
    $ab_test_id = abtestkit_sanitize_test_id( $ab_test_id );
    $variant    = ( $variant === 'A' || $variant === 'B' ) ? $variant : '';
    $allowed    = [ 'impression', 'click', 'purchase', 'decision', 'decision_applied', 'stale', 'protocol_warning' ];
    $event_type = in_array( $type, $allowed, true ) ? $type : 'impression';

    // Privacy-safe storage: hash IP / UA so we keep light dedupe value without raw personal data.
    $ip_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
    $ip     = '';
    if ( filter_var( $ip_raw, FILTER_VALIDATE_IP ) ) {
        $ip = substr( hash_hmac( 'sha256', $ip_raw, wp_salt( 'auth' ) ), 0, 32 );
    }

    $ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
    $ua     = '';
    if ( $ua_raw !== '' ) {
        $ua = substr( hash_hmac( 'sha256', $ua_raw, wp_salt( 'auth' ) ), 0, 32 );
    }

    $order_id = 0;
    if ( isset( $extra['order_id'] ) ) {
        $order_id = absint( $extra['order_id'] );
    }

    $amount = null;
    if ( isset( $extra['amount'] ) && is_numeric( $extra['amount'] ) ) {
        $amount = number_format( (float) $extra['amount'], 2, '.', '' );
    }

    $protocol = '';
    if ( isset( $extra['protocol'] ) ) {
        $protocol = sanitize_key( (string) $extra['protocol'] );
    }

    $excluded_reason = '';
    if ( isset( $extra['excluded_reason'] ) ) {
        $excluded_reason = sanitize_key( (string) $extra['excluded_reason'] );
    }

    /*
     * Final safety net:
     * never write normal tracking rows for non-HTTPS frontend requests,
     * even if a higher-level caller reaches this function by mistake.
     */
    if ( $event_type !== 'protocol_warning' ) {
        // Absolute request-level guard.
        if ( ! abtestkit_request_is_real_https() ) {
            return;
        }

        // Caller-provided protocol guard.
        if ( $protocol === 'http' ) {
            return;
        }

        // Matching page-test request-context guard.
        if (
            isset( $GLOBALS['abtestkit_current_pt_assignment'] )
            && is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
        ) {
            $ctx = $GLOBALS['abtestkit_current_pt_assignment'];

            $ctx_protocol = isset( $ctx['protocol'] )
                ? sanitize_key( (string) $ctx['protocol'] )
                : '';

            $ctx_no_track = ! empty( $ctx['no_track'] );

            $ctx_test_id = '';
            if ( isset( $ctx['test'] ) && is_array( $ctx['test'] ) && isset( $ctx['test']['id'] ) ) {
                $ctx_test_id = abtestkit_sanitize_test_id( (string) $ctx['test']['id'] );
            }

            if (
                $ctx_test_id !== ''
                && $ctx_test_id === $ab_test_id
                && ( $ctx_no_track || $ctx_protocol === 'http' )
            ) {
                return;
            }
        }
    }

    $now = current_time( 'mysql' );

    $data = [
        'time'            => $now,
        'post_id'         => absint( $post_id ),
        'ab_test_id'      => $ab_test_id,
        'variant'         => $variant,
        'event_type'      => $event_type,
        'order_id'        => $order_id > 0 ? $order_id : null,
        'amount'          => $amount,
        'protocol'        => $protocol !== '' ? $protocol : null,
        'excluded_reason' => $excluded_reason !== '' ? $excluded_reason : null,
        'ip'              => $ip,
        'user_agent'      => $ua,
    ];

    $format = [
        '%s', // time
        '%d', // post_id
        '%s', // ab_test_id
        '%s', // variant
        '%s', // event_type
        '%d', // order_id
        '%s', // amount
        '%s', // protocol
        '%s', // excluded_reason
        '%s', // ip
        '%s', // user_agent
    ];

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
    $wpdb->insert( ABTESTKIT_EVENTS_TABLE, $data, $format );

    // Invalidate per-post/test stats cache so dashboard + performance reflect new rows.
    wp_cache_delete( "stats:$post_id:$ab_test_id", 'abtestkit_stats' );
    wp_cache_delete( "stats_multi:$post_id", 'abtestkit_stats' );
    abtestkit_pt_flush_test_caches( (string) $ab_test_id );
}

// Save A/B test variants into post meta on save
add_action('save_post', function ($post_id) {
    if (wp_is_post_revision($post_id)) return;

    $content = get_post_field('post_content', $post_id);
    $blocks = parse_blocks($content);
    $variants = [];

    $extract_variants = function($blocks) use (&$extract_variants, &$variants) {
        foreach ($blocks as $block) {
            if (!is_array($block) || !isset($block['blockName'])) continue;

            $attrs = $block['attrs'] ?? [];
            $abTestId = $attrs['abTestId'] ?? null;
            $abTestVariants = $attrs['abTestVariants'] ?? null;

            if ($abTestId && $abTestVariants && isset($abTestVariants[$abTestId])) {
                $variants[$abTestId] = $abTestVariants[$abTestId];
                $locked = $abTestVariants[$abTestId]['locked'] ?? true;
                $variants[$abTestId]['running'] = !$locked;
            }

            // Recursively scan innerBlocks if needed
            if (!empty($block['innerBlocks'])) {
                $extract_variants($block['innerBlocks']);
            }
        }
    };

    $extract_variants($blocks);
    update_post_meta($post_id, '_abtestkit_variants', $variants);
});

// Frontend-only injection of data-ab-test-id
add_filter('render_block', function ($block_content, $block) {
    if (is_admin()) return $block_content; // don't touch editor

    $name = $block['blockName'] ?? '';
    $supported = ['core/button','core/heading','core/paragraph','core/image'];
    if (!in_array($name, $supported, true)) return $block_content;
    if (!$block_content) return $block_content;

    // Use existing abTestId if present; otherwise make a stable one
    $ab_id = $block['attrs']['abTestId'] ?? '';
    if (!$ab_id) {
        $post_id = get_the_ID() ?: 0;
        $seed = $post_id . '|' . $name . '|' . wp_json_encode($block['attrs'] ?? []);
        $ab_id = 'ab-' . substr(md5($seed), 0, 9);
    }

    // Inject attributes with DOMDocument
    $html = '<div id="__abtest_wrap__">'.$block_content.'</div>';
    libxml_use_internal_errors(true);
    $dom = new DOMDocument('1.0', 'UTF-8');
    $dom->loadHTML('<?xml encoding="utf-8" ?>'.$html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    libxml_clear_errors();

    $wrap = $dom->getElementById('__abtest_wrap__');
    if (!$wrap || !$wrap->firstChild) return $block_content;

    // Add data-ab-test-id on outermost element
    $root = $wrap->firstChild;
    if ($root instanceof DOMElement && !$root->hasAttribute('data-ab-test-id')) {
        $root->setAttribute('data-ab-test-id', $ab_id);
    }

        // 🔗 Group Sync: if this block is configured to sync, tag the DOM with data-ab-group
    $attrs = $block['attrs'] ?? [];
    if ( ! empty( $attrs['abSync'] ) && ! empty( $attrs['abGroupKey'] ) ) {
        $group = sanitize_key( (string) $attrs['abGroupKey'] );
        if ( $group && $root instanceof DOMElement && ! $root->hasAttribute('data-ab-group') ) {
            $root->setAttribute('data-ab-group', $group);
        }
    }

    // For buttons, also add it on <a.wp-block-button__link>
    if ($name === 'core/button') {
        $xpath = new DOMXPath($dom);
        $aList = $xpath->query('//*[@class and contains(concat(" ", normalize-space(@class), " "), " wp-block-button__link ")]');
        foreach ($aList as $a) {
            if ($a instanceof DOMElement && !$a->hasAttribute('data-ab-test-id')) {
                $a->setAttribute('data-ab-test-id', $ab_id);
            }
        }
    }

    // Return inner HTML
    $out = '';
    foreach ($wrap->childNodes as $child) {
        $out .= $dom->saveHTML($child);
    }
    return $out;
}, 10, 2);

/**
 * Dynamically extend ACF blocks to include abTestId and abTestVariants attributes.
 */
add_filter('register_block_type_args', function($args, $block_name) {
    if ($block_name === 'acf/bv-panel') {
        $args['attributes']['abTestId'] = [
            'type' => 'string',
            'default' => '',
        ];
        $args['attributes']['abTestVariants'] = [
            'type' => 'object',
            'default' => [],
        ];
        $args['attributes']['abTestEnabled'] = [
            'type' => 'boolean',
            'default' => false,
        ];
        $args['attributes']['abTestRunning'] = [
            'type' => 'boolean',
            'default' => false,
        ];
        $args['attributes']['abTestWinner'] = [
            'type' => 'string',
            'default' => '',
        ];
    }
    return $args;
}, 10, 2);


// 3️⃣ Beta + Gamma samplers
function abtestkit_sample_beta($alpha, $beta) {
  $x = abtestkit_sample_gamma($alpha, 1.0);
  $y = abtestkit_sample_gamma($beta,  1.0);
  return $x / ($x + $y);
}

function abtestkit_sample_gamma($shape, $scale) {
  if ($shape >= 1) {
    $d = $shape - 1/3;
    $c = 1 / sqrt(9 * $d);
    while (true) {
      do {
        $u = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
        $v = 1 + $c * abtestkit_normal_rand();
      } while ($v <= 0);
      $v = $v * $v * $v;
      $u2 = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
      if ($u2 < 1 - 0.0331 * pow(abtestkit_normal_rand(), 4)) break;
      if (log($u2) < 0.5 * pow(abtestkit_normal_rand(), 2) + $d * (1 - $v + log($v))) break;
    }
    return $d * $v * $scale;
  }
  $u = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
  return abtestkit_sample_gamma($shape + 1, $scale) * pow( max( $u, 1.0 / PHP_INT_MAX ), 1 / $shape );

}

function abtestkit_normal_rand() {
    static $useLast = false, $y2;

    if ( $useLast ) {
        $useLast = false;
        return $y2;
    }

    // Ensure u1 ∈ (0,1) to avoid log(0)
    do {
        $u1 = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;
    } while ($u1 <= 0.0 || $u1 >= 1.0);

    $u2 = wp_rand(0, PHP_INT_MAX) / PHP_INT_MAX;

    $r  = sqrt(-2.0 * log($u1));
    $t  = 2.0 * pi() * $u2;

    $x1 = $r * cos($t);
    $y2 = $r * sin($t);

    $useLast = true;
    return $x1;
}

/**
 * Sample Revenue Per Visitor for purchase-goal tests.
 *
 * Model:
 * - purchase rate ~ Beta posterior
 * - average order value ~ Gamma approximation around observed AOV
 * - RPV sample = sampled purchase rate * sampled AOV
 *
 * This keeps purchase-goal auto-winning focused on money per visitor,
 * while still accounting for uncertainty in both order rate and order value.
 */
function abtestkit_sample_rpv( int $impressions, int $purchases, float $revenue ): float {
    $impressions = max( 0, $impressions );
    $purchases   = max( 0, $purchases );
    $revenue     = max( 0.0, $revenue );

    // Purchase-rate posterior
    $rate_sample = abtestkit_sample_beta(
        1 + $purchases,
        1 + max( 0, $impressions - $purchases )
    );

    // If there are no orders or no revenue, RPV is driven to zero.
    if ( $purchases <= 0 || $revenue <= 0 ) {
        return 0.0;
    }

    $aov = $revenue / $purchases;

    /*
     * Gamma approximation for AOV uncertainty:
     * mean  = AOV
     * var   = AOV^2 / purchases
     * shape = purchases
     * scale = AOV / purchases
     */
    $shape = max( 1, $purchases );
    $scale = max( 0.0001, $aov / $shape );

    $aov_sample = abtestkit_sample_gamma( $shape, $scale );

    return max( 0.0, $rate_sample * $aov_sample );
}

/**
 * ─────────────────────────────────────────────────────────────────────────────
 * PHP-side variant rendering for core/image, core/heading, core/paragraph, core/button
 * Pattern: wrapper [data-ab-test-id], 2 children [data-ab-variant="A|B"] hidden by default
 * ─────────────────────────────────────────────────────────────────────────────
 */
function abtestkit_can_render_variants(array $block): bool {
    $attrs = $block['attrs'] ?? [];
    if (empty($attrs['abTestEnabled']) || empty($attrs['abTestId'])) return false;
    $id = $attrs['abTestId'];
    $all = $attrs['abTestVariants'][$id] ?? [];
    return is_array($all) && isset($all['A']) && isset($all['B']);
}

function abtestkit_pick($arr, $keys, $fallback = '') {
    foreach ((array)$keys as $k) {
        if (isset($arr[$k]) && $arr[$k] !== '') return $arr[$k];
    }
    return $fallback;
}

function abtestkit_clean_id($id) {
    return preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$id);
}

/**
 * core/image → outputs <figure data-ab-test-id> with two <img data-ab-variant>
 */
add_filter('render_block_core/image', function($html, $block){
    if (!abtestkit_can_render_variants($block)) return $html;

    $attrs   = $block['attrs'];
    $id      = abtestkit_clean_id($attrs['abTestId']);
    $varArr  = $attrs['abTestVariants'][$id];

    $A = $varArr['A'] ?? [];
    $B = $varArr['B'] ?? [];

    // Try common keys; fall back to existing markup’s <img src>
    $existing_src = '';
    if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/i', $html, $m)) {
        $existing_src = $m[1];
    }

    $srcA = abtestkit_pick($A, ['url','src','image','img'], $existing_src);
    $srcB = abtestkit_pick($B, ['url','src','image','img'], $existing_src);

    // Alt text if present in variants, otherwise try to scrape from existing
    $altExisting = '';
    if (preg_match('/<img[^>]+alt=["\']([^"\']*)["\']/i', $html, $m)) {
        $altExisting = $m[1];
    }
    $altA = abtestkit_pick($A, ['alt','alt_text'], $altExisting);
    $altB = abtestkit_pick($B, ['alt','alt_text'], $altExisting);

    // Preserve figcaption if present
    $caption = '';
    if (preg_match('/<figcaption[^>]*>.*?<\/figcaption>/is', $html, $m)) {
        $caption = $m[0];
    }

    $groupAttr = '';
    $attrsAll  = $block['attrs'] ?? [];
    if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
        $groupAttr = ' data-ab-group="' . esc_attr( sanitize_key( (string) $attrsAll['abGroupKey'] ) ) . '"';
    }

    $out  = '<figure class="wp-block-image" data-ab-test-id="' . esc_attr($id) . '"' . $groupAttr . '>';
    $out .= '<img data-ab-variant="A" style="display:none" src="' . esc_url($srcA) . '" alt="' . esc_attr($altA) . '" />';
    $out .= '<img data-ab-variant="B" style="display:none" src="' . esc_url($srcB) . '" alt="' . esc_attr($altB) . '" />';
    $out .= $caption;
    $out .= '</figure>';

    return $out;
}, 10, 2);

/**
 * core/heading → wraps one heading tag containing two spans with variant text
 */
add_filter('render_block_core/heading', function($html, $block){
    if (!abtestkit_can_render_variants($block)) return $html;

    $attrs   = $block['attrs'];
    $id      = abtestkit_clean_id($attrs['abTestId']);
    $varArr  = $attrs['abTestVariants'][$id];
    $A = $varArr['A'] ?? [];
    $B = $varArr['B'] ?? [];

    // Determine tag (h1..h6); fall back to h2
    $tag = 'h2';
    if (preg_match('/<(h[1-6])\b/i', $html, $m)) $tag = strtolower($m[1]);

    // Variant text: prefer 'content' then 'text'; fallback to stripped existing
    $existing = trim( wp_strip_all_tags( $html, true ) );
    $textA = abtestkit_pick($A, ['content','text','html'], $existing);
    $textB = abtestkit_pick($B, ['content','text','html'], $existing);

    $groupAttr = '';
    $attrsAll  = $block['attrs'] ?? [];
    if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
        $groupAttr = ' data-ab-group="' . esc_attr( sanitize_key( (string) $attrsAll['abGroupKey'] ) ) . '"';
    }
    $out  = '<' . $tag . ' data-ab-test-id="' . esc_attr($id) . '"' . $groupAttr . '>';
    $out .= '<span data-ab-variant="A" style="display:none">' . wp_kses_post($textA) . '</span>';
    $out .= '<span data-ab-variant="B" style="display:none">' . wp_kses_post($textB) . '</span>';
    $out .= '</' . $tag . '>';

    return $out;
}, 10, 2);

/**
 * core/paragraph → renders two spans with variant text inside a <p>
 */
add_filter('render_block_core/paragraph', function($html, $block){
    if (!abtestkit_can_render_variants($block)) return $html;

    $attrs   = $block['attrs'];
    $id      = abtestkit_clean_id($attrs['abTestId']);
    $varArr  = $attrs['abTestVariants'][$id];
    $A = $varArr['A'] ?? [];
    $B = $varArr['B'] ?? [];

    $existing = trim( wp_strip_all_tags( $html, true ) );
    $textA = abtestkit_pick($A, ['content','text','html'], $existing);
    $textB = abtestkit_pick($B, ['content','text','html'], $existing);

    $groupAttr = '';
    $attrsAll  = $block['attrs'] ?? [];
    if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
     $groupAttr = ' data-ab-group="' . esc_attr( sanitize_key( (string) $attrsAll['abGroupKey'] ) ) . '"';
    }
    $out  = '<p data-ab-test-id="' . esc_attr($id) . '"' . $groupAttr . '>';

    $out .= '<span data-ab-variant="A" style="display:none">' . wp_kses_post($textA) . '</span>';
    $out .= '<span data-ab-variant="B" style="display:none">' . wp_kses_post($textB) . '</span>';
    $out .= '</p>';

    return $out;
}, 10, 2);

/**
 * core/button → your plugin already did this; here’s a hardened version.
 * Keeps original attributes, injects two label spans. (URL can be variant too.)
 */
add_filter('render_block_core/button', function ($html, $block) {
    // Only touch when an AB test is actually running on this button
    if (empty($block['attrs']['abTestEnabled']) || empty($block['attrs']['abTestId'])) {
        return $html;
    }

    $id   = abtestkit_clean_id($block['attrs']['abTestId']);
    $vars = $block['attrs']['abTestVariants'][$id] ?? [];
    if (empty($vars['A']) || empty($vars['B'])) return $html;

    // Parse the existing <a> so we preserve ALL original attributes/classes (incl. Popup Maker)
    libxml_use_internal_errors(true);
    $dom = new DOMDocument('1.0', 'UTF-8');
    $dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    libxml_clear_errors();

    $a = $dom->getElementsByTagName('a')->item(0);
    if (!$a) return $html;

    // Add AB markers, but DO NOT touch href/class/data-* from the original block
    $a->setAttribute('data-ab-test-id', $id);
    $a->setAttribute('data-ab-index', '0');

    $attrsAll = $block['attrs'] ?? [];
    if ( ! empty( $attrsAll['abSync'] ) && ! empty( $attrsAll['abGroupKey'] ) ) {
        $group = sanitize_key( (string) $attrsAll['abGroupKey'] );
        if ( $group ) {
            $a->setAttribute('data-ab-group', $group);
        }
    }

    // Current visible label (fallback)
    $existing = trim($a->textContent);

    // Compute variant labels (prefer content/text if provided)
    $labelA = '';
    $labelB = '';
    $labelA = isset($vars['A']['content']) ? $vars['A']['content'] :
              (isset($vars['A']['text']) ? $vars['A']['text'] : $existing);
    $labelB = isset($vars['B']['content']) ? $vars['B']['content'] :
              (isset($vars['B']['text']) ? $vars['B']['text'] : $existing);

    // Optional: attach variant URLs to the span as data-href (frontend.js already reads this)
    $urlA = '';
    foreach (['url','myButtonURL','href'] as $k) { if (!empty($vars['A'][$k])) { $urlA = $vars['A'][$k]; break; } }
    $urlB = '';
    foreach (['url','myButtonURL','href'] as $k) { if (!empty($vars['B'][$k])) { $urlB = $vars['B'][$k]; break; } }

    // Remove plain text nodes (keep icons/elements) before inserting our spans
    for ($i = $a->childNodes->length - 1; $i >= 0; $i--) {
        $n = $a->childNodes->item($i);
        if ($n->nodeType === XML_TEXT_NODE) {
            $a->removeChild($n);
        }
    }

    // Build hidden A & B spans (JS reveals the assigned one)
    $spanA = $dom->createElement('span');
    $spanA->setAttribute('data-ab-variant', 'A');
    $spanA->setAttribute('style', 'display:none');
    $spanA->appendChild($dom->createTextNode($labelA));
    if ($urlA !== '') $spanA->setAttribute('data-href', esc_url_raw($urlA));

    $spanB = $dom->createElement('span');
    $spanB->setAttribute('data-ab-variant', 'B');
    $spanB->setAttribute('style', 'display:none');
    $spanB->appendChild($dom->createTextNode($labelB));
    if ($urlB !== '') $spanB->setAttribute('data-href', esc_url_raw($urlB));

    $a->appendChild($spanA);
    $a->appendChild($spanB);

    // Return just the updated fragment
    return $dom->saveHTML($dom->documentElement);
}, 10, 2);

/**
 * ACF block (bv-panel) wrapper injection (minimal): wrap existing HTML with data-ab-test-id
 * and append two transparent markers if you need them later.
 */
add_filter('render_block_acf/bv-panel', function($html, $block){
    if (!abtestkit_can_render_variants($block)) return $html;
    $id = abtestkit_clean_id($block['attrs']['abTestId']);
    // If it already starts with a tag, inject attribute on the first tag.
    if (preg_match('/^<([a-z0-9:-]+)\b/i', $html, $m)) {
        $tag = $m[1];
        return preg_replace(
            '/^<' . preg_quote($tag, '/') . '\b(?![^>]*data-ab-test-id)/i',
            '<' . $tag . ' data-ab-test-id="' . esc_attr($id) . '"',
            $html,
            1
        );
    }
    // Fallback: wrap
    return '<div data-ab-test-id="' . esc_attr($id) . '">' . $html . '</div>';
}, 10, 2);


/**
 * Accept only requests coming from this site (same-origin).
 */
function abtestkit_safe_set_cookie( string $name, string $value, int $expires = 0 ): bool {
    $path     = COOKIEPATH ?: '/';
    $domain   = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
    $secure   = abtestkit_request_is_real_https();
    $httponly = true;

    if ( ! headers_sent() ) {
        return setcookie(
            $name,
            $value,
            $expires,
            $path,
            $domain,
            $secure,
            $httponly
        );
    }

    // Fallback for WooCommerce flows when output has already started.
    // This avoids frontend warnings while still keeping assignment/click state alive.
    if ( function_exists( 'WC' ) && WC() && isset( WC()->session ) && WC()->session ) {
        WC()->session->set( 'abtk_cookie_' . $name, [
            'value'   => $value,
            'expires' => $expires,
        ] );
        return false;
    }

    return false;
}

function abtestkit_safe_get_cookie_value( string $name ): string {
    if ( isset( $_COOKIE[ $name ] ) ) {
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
        return sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) );
    }

    if ( function_exists( 'WC' ) && WC() && isset( WC()->session ) && WC()->session ) {
        $stored = WC()->session->get( 'abtk_cookie_' . $name );
        if ( is_array( $stored ) && isset( $stored['value'] ) ) {
            return sanitize_text_field( (string) $stored['value'] );
        }
    }

    return '';
}

/**
 * Trackability must reflect the real frontend request, not a misleading proxy hint.
 *
 * Deliberately DO NOT trust HTTP_X_FORWARDED_PROTO / HTTP_X_FORWARDED_SSL here,
 * because this site is proving they can say "https" while the browser is on an
 * actual http:// page.
 */
function abtestkit_request_is_real_https(): bool {
    $allowed_server_keys = [
        'REQUEST_SCHEME',
        'HTTPS',
        'HTTP_X_FORWARDED_PROTO',
        'HTTP_X_FORWARDED_SCHEME',
        'HTTP_X_FORWARDED_SSL',
        'HTTP_FRONT_END_HTTPS',
        'HTTP_X_URL_SCHEME',
        'HTTP_CF_VISITOR',
        'SERVER_PORT',
    ];

    $server_value = static function ( string $key ) use ( $allowed_server_keys ): string {
        if ( ! in_array( $key, $allowed_server_keys, true ) ) {
            return '';
        }

        if ( ! isset( $_SERVER[ $key ] ) ) {
            return '';
        }

        return strtolower( trim( sanitize_text_field( wp_unslash( (string) $_SERVER[ $key ] ) ) ) );
    };

    $csv_server_value = static function ( string $key ) use ( $server_value ): string {
        $raw = $server_value( $key );
        if ( $raw === '' ) {
            return '';
        }

        $parts = array_map( 'trim', explode( ',', $raw ) );
        return (string) ( $parts[0] ?? '' );
    };

    $scheme            = $csv_server_value( 'REQUEST_SCHEME' );
    $https             = $csv_server_value( 'HTTPS' );
    $forwarded_proto   = $csv_server_value( 'HTTP_X_FORWARDED_PROTO' );
    $forwarded_scheme  = $csv_server_value( 'HTTP_X_FORWARDED_SCHEME' );
    $forwarded_ssl     = $csv_server_value( 'HTTP_X_FORWARDED_SSL' );
    $front_end_https   = $csv_server_value( 'HTTP_FRONT_END_HTTPS' );
    $url_scheme        = $csv_server_value( 'HTTP_X_URL_SCHEME' );
    $cf_visitor_raw    = $server_value( 'HTTP_CF_VISITOR' );
    $cf_visitor_scheme = '';

    if ( $cf_visitor_raw !== '' ) {
        $cf_visitor = json_decode( $cf_visitor_raw, true );
        if ( is_array( $cf_visitor ) && isset( $cf_visitor['scheme'] ) ) {
            $cf_visitor_scheme = strtolower( trim( sanitize_text_field( (string) $cf_visitor['scheme'] ) ) );
        }
    }

    $port_raw = $server_value( 'SERVER_PORT' );
    $port     = ctype_digit( $port_raw ) ? (int) $port_raw : 0;

    /*
     * Be deliberately strict.
     *
     * This site has already proven it can present a real browser HTTP request
     * while PHP still thinks HTTPS is on somewhere upstream.
     *
     * So explicit plain-HTTP signals must win first.
     */
    if ( $scheme === 'http' ) {
        return false;
    }

    if ( $forwarded_proto === 'http' ) {
        return false;
    }

    if ( $forwarded_scheme === 'http' ) {
        return false;
    }

    if ( $url_scheme === 'http' ) {
        return false;
    }

    if ( $forwarded_ssl === 'off' || $forwarded_ssl === '0' ) {
        return false;
    }

    if ( $front_end_https === 'off' || $front_end_https === '0' ) {
        return false;
    }

    if ( $https === 'off' || $https === '0' ) {
        return false;
    }

    if ( $cf_visitor_scheme === 'http' ) {
        return false;
    }

    if ( $port === 80 ) {
        return false;
    }

    if ( $scheme === 'https' ) {
        return true;
    }

    if ( $forwarded_proto === 'https' ) {
        return true;
    }

    if ( $forwarded_scheme === 'https' ) {
        return true;
    }

    if ( $url_scheme === 'https' ) {
        return true;
    }

    if ( $forwarded_ssl === 'on' || $forwarded_ssl === '1' ) {
        return true;
    }

    if ( $front_end_https === 'on' || $front_end_https === '1' ) {
        return true;
    }

    if ( $cf_visitor_scheme === 'https' ) {
        return true;
    }

    if ( $port === 443 ) {
        return true;
    }

    if ( $https === 'on' || $https === '1' ) {
        return true;
    }

    return false;
}

function abtestkit_is_same_origin_request(): bool {
    $origin  = isset( $_SERVER['HTTP_ORIGIN'] )  ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) )  : '';
    $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';


    $host    = wp_parse_url( home_url(), PHP_URL_HOST );

    if ($origin) {
        $o = wp_parse_url($origin, PHP_URL_HOST);
        if ($o && $o === $host) return true;
    }
    if ($referer) {
        $r = wp_parse_url($referer, PHP_URL_HOST);
        if ($r && $r === $host) return true;
    }
    return false;
}

// Admins (or any role/cap decided via filter) are exempt from tests/tracking.
// They should always see the original (Version A / Control) and not be counted.
function abtestkit_is_exempt_viewer(): bool {
    $is_exempt_cap = is_user_logged_in() && ( current_user_can( 'manage_options' ) || current_user_can( 'edit_posts' ) );
    /**
     * Filter: allow site owners to widen/narrow who is exempt.
     * Return true to exempt (no test, no logging, show original).
     */
    return (bool) apply_filters( 'abtestkit_is_exempt_viewer', $is_exempt_cap );
}



// Quick existence check: does this Page Test ID belong to this post?
// Treat both control AND variant pages as valid owners of the test.
function abtestkit_pt_test_id_belongs_to_post( int $post_id, string $ab_test_id ): bool {
    foreach ( abtestkit_pt_all() as $t ) {
        if ( ! isset( $t['id'] ) || $t['id'] !== $ab_test_id ) {
            continue;
        }

        $control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
        $variant_id = isset( $t['variant_id'] ) ? (int) $t['variant_id'] : 0;

        // For page/product tests, a hit on either A or B is valid.
        if ( $control_id === (int) $post_id || $variant_id === (int) $post_id ) {
            return true;
        }
    }
    return false;
}

function abtestkit_test_id_exists_on_post( int $post_id, string $ab_test_id ): bool {
    // Allow Page-Test IDs that belong to this control post
    if ( abtestkit_pt_test_id_belongs_to_post( $post_id, $ab_test_id ) ) return true;

    $variants = get_post_meta( $post_id, '_abtestkit_variants', true );
    if ( is_array( $variants ) && isset( $variants[ $ab_test_id ] ) ) return true;

    $content = get_post_field( 'post_content', $post_id );
    if ( ! $content ) return false;
    $blocks = parse_blocks( $content );
    $found  = false;
    $scan = function( $blocks ) use ( &$scan, &$found, $ab_test_id ) {
        foreach ( $blocks as $b ) {
            if ( ! is_array( $b ) ) continue;
            $attrs = $b['attrs'] ?? [];
            if ( ( $attrs['abTestId'] ?? '' ) === $ab_test_id ) { $found = true; return; }
            if ( ! empty( $b['innerBlocks'] ) ) $scan( $b['innerBlocks'] );
        }
    };
    $scan( $blocks );
    return $found;
}


function abtestkit_handle_track( WP_REST_Request $request ) {
    // Parse + sanitize JSON body
    $raw     = $request->get_body();
    $body    = is_string( $raw ) && $raw !== '' ? json_decode( $raw, true ) : [];
    $type            = sanitize_text_field( $body['type'] ?? '' );
    $post_id         = absint( $body['postId'] ?? 0 );
    $index           = absint( $body['index'] ?? 0 );
    $variant         = $body['variant'] ?? '';
    $ab_id           = abtestkit_sanitize_test_id( $body['abTestId'] ?? '' );
    $protocol        = sanitize_key( (string) ( $body['protocol'] ?? '' ) );
    $excluded_reason = sanitize_key( (string) ( $body['excluded_reason'] ?? '' ) );

    // Public tracking must come from a real same-origin browser request.
    $nonce_ok  = abtestkit_rest_check_nonce( $request );
    $origin_ok = abtestkit_is_same_origin_request();

    if ( ! $origin_ok && ! $nonce_ok ) {
        return rest_ensure_response( [
            'success' => false,
            'error'   => 'Unauthorised: same-origin browser request required.',
        ] );
    }

    $valid_types   = [ 'impression', 'click', 'decision', 'decision_applied', 'stale', 'protocol_warning' ];
    $needs_variant = in_array( $type, [ 'impression', 'click', 'decision', 'decision_applied', 'protocol_warning' ], true );

    if (
        empty( $ab_id ) ||
        ! in_array( $type, $valid_types, true ) ||
        ( $needs_variant && ! in_array( $variant, [ 'A', 'B' ], true ) ) ||
        $post_id <= 0
    ) {
        return rest_ensure_response( [
            'success' => false,
            'error'   => 'Invalid tracking payload',
        ] );
    }

    $pt = null;

    // For Page Tests: resolve the test once so protocol_warning can validate
    // against the real PT record without depending on block/post scan logic.
    if ( strpos( $ab_id, 'pt-' ) === 0 ) {
        $pt = abtestkit_pt_get( (string) $ab_id );

        if ( ! is_array( $pt ) ) {
            return rest_ensure_response( [ 'success' => true ] );
        }

        if ( ( $pt['status'] ?? 'paused' ) !== 'running' ) {
            return rest_ensure_response( [ 'success' => true ] );
        }
    }

    // Admins should never log events.
    if ( abtestkit_is_exempt_viewer() ) {
        return rest_ensure_response( [ 'success' => true ] );
    }

    // Allow a lightweight diagnostic row for excluded HTTP visits,
    // but continue blocking all normal tracking on HTTP.
    if ( $type === 'protocol_warning' ) {
        if ( $protocol !== 'http' || $excluded_reason !== 'http' ) {
            return rest_ensure_response( [ 'success' => true ] );
        }

        // For page/product tests, validate against the stored PT record directly.
        if ( is_array( $pt ) ) {
            $control_id = (int) ( $pt['control_id'] ?? 0 );
            $variant_id = (int) ( $pt['variant_id'] ?? 0 );

            if ( $post_id !== $control_id && ( $variant_id <= 0 || $post_id !== $variant_id ) ) {
                return rest_ensure_response( [ 'success' => true ] );
            }
        } elseif ( ! abtestkit_test_id_exists_on_post( $post_id, $ab_id ) ) {
            return rest_ensure_response( [ 'success' => true ] );
        }

        abtestkit_log_event_to_db(
            'protocol_warning',
            $post_id,
            $ab_id,
            in_array( $variant, [ 'A', 'B' ], true ) ? $variant : 'A',
            [
                'protocol'        => 'http',
                'excluded_reason' => 'http',
            ]
        );

        abtestkit_pt_flush_test_caches( (string) $ab_id );

        return rest_ensure_response( [ 'success' => true ] );
    }

    // Ensure the test actually exists on this post for normal tracking.
    if ( ! abtestkit_test_id_exists_on_post( $post_id, $ab_id ) ) {
        return rest_ensure_response( [
            'success' => false,
            'error'   => 'Unknown test on this post',
        ] );
    }

    // Respect the browser's reported page protocol when the JS tracker sends it.
    if ( $protocol === 'http' ) {
        return rest_ensure_response( [ 'success' => true ] );
    }

    // Belt-and-braces: reject normal event writes if the real frontend request is not HTTPS.
    if ( ! abtestkit_request_is_real_https() ) {
        return rest_ensure_response( [ 'success' => true ] );
    }

    // Simple rate limit: max 120 events/minute per IP + test.
    $ip_for_limit_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
    $ip_for_limit     = filter_var( $ip_for_limit_raw, FILTER_VALIDATE_IP ) ? $ip_for_limit_raw : 'unknown';

    $limit_key     = 'abtestkit_trk_' . md5( $ip_for_limit . '|' . $post_id . '|' . $ab_id . '|' . gmdate( 'YmdHi' ) );
    $current_count = (int) get_transient( $limit_key );
    if ( $current_count > 120 ) {
        return rest_ensure_response( [ 'success' => true ] );
    }
    set_transient( $limit_key, $current_count + 1, MINUTE_IN_SECONDS + 5 );

    // One click per session per variant.
    if ( $type === 'click' ) {
        $click_cookie_name = 'abtestkit_pt_click_' . $post_id . '_' . $ab_id . '_' . $variant;
        $already_clicked   = ( abtestkit_safe_get_cookie_value( $click_cookie_name ) === '1' );

        if ( $already_clicked ) {
            return rest_ensure_response( [ 'success' => true ] );
        }

        abtestkit_safe_set_cookie( $click_cookie_name, '1', 0 );
    }

    abtestkit_log_event_to_db(
        $type,
        $post_id,
        $ab_id,
        $variant,
        [
            'protocol' => 'https',
        ]
    );

    if ( $type === 'click' && strpos( $ab_id, 'pt-' ) === 0 ) {
        abtestkit_pt_maybe_lock_winner( (string) $ab_id );
    }

    return rest_ensure_response( [ 'success' => true ] );
}


// ─────────────────────────────────────────────────────────────────────────────
// Force no-cache headers on abtestkit REST responses (works with WP Rocket/CDNs)
// ─────────────────────────────────────────────────────────────────────────────
add_filter('rest_post_dispatch', function( $result, $server, $request ) {
    $route = $request->get_route();
    if ( strpos( $route, '/abtestkit/' ) !== false ) {
        // Always a WP_REST_Response by here, but guard anyway
        if ( is_a( $result, WP_REST_Response::class ) ) {
            $result->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
            $result->header( 'Pragma', 'no-cache' );
        } else {
            $resp = new WP_REST_Response( $result );
            $resp->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
            $resp->header( 'Pragma', 'no-cache' );
            return $resp;
        }
    }
    return $result;
}, 10, 3);

//Handler for /stats


function abtestkit_handle_stats( WP_REST_Request $request ) {
    global $wpdb;

    $post_id = absint( $request->get_param( 'post_id' ) );
    if ( $post_id <= 0 ) {
        return rest_ensure_response([
            'A' => [ 'impressions' => 0, 'clicks' => 0 ],
            'B' => [ 'impressions' => 0, 'clicks' => 0 ],
        ]);
    }

    // Accept either a single abTestId or multiple abTestIds
    $single = $request->get_param( 'abTestId' );
    $many   = $request->get_param( 'abTestIds' );

    // Normalise to an array of sanitized IDs
    $ab_ids = [];
    if ( is_array( $many ) ) {
        foreach ( $many as $id ) {
            $clean = abtestkit_sanitize_test_id( $id );
            if ( $clean !== '' ) $ab_ids[] = $clean;
        }
    } elseif ( is_string( $many ) && $many !== '' ) {
        foreach ( preg_split( '/[,\|]/', $many ) as $id ) {
            $clean = abtestkit_sanitize_test_id( $id );
            if ( $clean !== '' ) $ab_ids[] = $clean;
        }
    } elseif ( $single ) {
        $clean = abtestkit_sanitize_test_id( $single );
        if ( $clean !== '' ) $ab_ids[] = $clean;
    }

    $ab_ids = array_values( array_unique( $ab_ids ) );

    if ( empty( $ab_ids ) ) {
        return rest_ensure_response([
            'A' => [ 'impressions' => 0, 'clicks' => 0 ],
            'B' => [ 'impressions' => 0, 'clicks' => 0 ],
        ]);
    }

    // ── Simple object cache for stats ───────────────────────────────────────────
    $cache_key = '';
    if ( count( $ab_ids ) === 1 ) {
      $cache_key = "stats:$post_id:{$ab_ids[0]}";
    } else {
     $cache_key = "stats_multi:$post_id";
    }
    $cached = wp_cache_get( $cache_key, 'abtestkit_stats' );
    if ( false !== $cached ) {
        return rest_ensure_response( $cached );
    }


    $table = ABTESTKIT_EVENTS_TABLE;

    if ( count( $ab_ids ) === 1 ) {
        $post_id_i = (int) $post_id;
        $ab_id     = isset( $ab_ids[0] ) ? sanitize_text_field( (string) $ab_ids[0] ) : '';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT ab_test_id, variant, event_type, COUNT(*) AS count ' .
                'FROM %i ' .
                'WHERE post_id = %d AND ab_test_id = %s ' .
                'GROUP BY ab_test_id, variant, event_type',
                $table,
                $post_id_i,
                $ab_id
            ),
            ARRAY_A
        );
    } else {
        $ab_ids_csv = implode(
            ',',
            array_map(
                static function( $id ) {
                    return abtestkit_sanitize_test_id( $id );
                },
                $ab_ids
            )
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT ab_test_id, variant, event_type, COUNT(*) AS count ' .
                'FROM %i ' .
                'WHERE post_id = %d AND FIND_IN_SET(ab_test_id, %s) > 0 ' .
                'GROUP BY ab_test_id, variant, event_type',
                $table,
                $post_id,
                $ab_ids_csv
            ),
            ARRAY_A
        );
    }

    $default_pair = [ 'A' => [ 'impressions' => 0, 'clicks' => 0 ],
                      'B' => [ 'impressions' => 0, 'clicks' => 0 ] ];

    if ( count( $ab_ids ) === 1 && isset( $ab_ids[0] ) && $single ) {
        // Back-compat single shape
        $stats = $default_pair;
        foreach ( $rows as $r ) {
            $v = $r['variant'];
            $t = $r['event_type'];
            $c = (int) $r['count'];
            if ( isset( $stats[$v][$t . 's'] ) ) {
                $stats[$v][$t . 's'] += $c;
            }
        }
        wp_cache_set( $cache_key, $stats, 'abtestkit_stats', MINUTE_IN_SECONDS );
        return rest_ensure_response( $stats );
    }

    // Multi shape keyed by test id
    $out = [];
    foreach ( $ab_ids as $id ) {
        $out[$id] = $default_pair;
    }
    foreach ( $rows as $r ) {
        $id = $r['ab_test_id'];
        $v  = $r['variant'];
        $t  = $r['event_type'];
        $c  = (int) $r['count'];
        if ( isset( $out[$id][$v][$t . 's'] ) ) {
            $out[$id][$v][$t . 's'] += $c;
        }
    }

    wp_cache_set( $cache_key, $out, 'abtestkit_stats', MINUTE_IN_SECONDS );
    return rest_ensure_response( $out );
}


/**
 * Handler for /evaluate — Bayesian with Beta(5,5) prior + 5-impression floor.
 */
function abtestkit_handle_evaluate( WP_REST_Request $request ) {
    $abTestId = abtestkit_sanitize_test_id( $request->get_param('abTestId') );
    $post_id  = absint( $request->get_param('post_id') );

    if ( empty( $abTestId ) || empty( $post_id ) ) {
        return rest_ensure_response([
            'error'   => 'Missing abTestId or post_id',
            'message' => 'Evaluation requires abTestId and post_id.'
        ]);
    }

    // Parse post content to find if this test has grouped dependencies (conversionFrom)
    $content = get_post_field('post_content', $post_id );
    $blocks  = parse_blocks( $content );
    $grouped = [];

    $scan = function( $blocks ) use ( &$scan, $abTestId, &$grouped ) {
        foreach ( $blocks as $block ) {
            if ( ! is_array( $block ) ) continue;
            $attrs = $block['attrs'] ?? [];
            if ( ( $attrs['abTestId'] ?? null ) === $abTestId && ! empty( $attrs['conversionFrom'] ) ) {
                $grouped = $attrs['conversionFrom'];
                return;
            }
            if ( ! empty( $block['innerBlocks'] ) ) {
                $scan( $block['innerBlocks'] );
            }
        }
    };
    $scan( $blocks );

    $conversionEvent = 'click';

    // Defaults (legacy)
    $decisionMode   = 'auto';
    $minImpressions = 50;
    $minConversions = 5;

    // Page Test Wizard (pt-*) thresholds + manual mode
    if ( strpos( (string) $abTestId, 'pt-' ) === 0 ) {
        $pt_test = abtestkit_pt_get( (string) $abTestId );
        if ( is_array( $pt_test ) ) {
            $decisionMode = sanitize_key( (string) ( $pt_test['decision_mode'] ?? 'auto' ) );
            $decisionRule = sanitize_key( (string) ( $pt_test['decision_rule'] ?? 'balanced' ) );
            $goal         = sanitize_key( (string) ( $pt_test['goal'] ?? '' ) );

            if ( $goal === 'purchase' ) {
                $conversionEvent = 'purchase';
            }

            if ( $decisionMode === 'manual' || $decisionRule === 'manual' ) {
                $decisionMode   = 'manual';
                $minImpressions = 0;
                $minConversions = 0;
            } else {
                $mi = isset( $pt_test['min_impressions'] ) ? absint( $pt_test['min_impressions'] ) : 0;
                $mc = isset( $pt_test['min_conversions'] ) ? absint( $pt_test['min_conversions'] ) : 0;

                $minImpressions = in_array( $mi, [ 25, 50, 75 ], true ) ? $mi : 50;
                $minConversions = in_array( $mc, [ 3, 5, 10 ], true ) ? $mc : 5;
            }
        }
    }

    // Get current stats for this test.
    // Purchase-goal tests are evaluated on RPV, not just raw purchase count.
    $revA = 0.0;
    $revB = 0.0;

    if ( $conversionEvent === 'purchase' ) {
        global $wpdb;

        $table = ABTESTKIT_EVENTS_TABLE;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
                'FROM %i ' .
                'WHERE ab_test_id = %s ' .
                "AND event_type IN ('impression','purchase') " .
                'GROUP BY variant, event_type',
                $table,
                (string) $abTestId
            ),
            ARRAY_A
        );

        $impA = 0;
        $clkA = 0;
        $impB = 0;
        $clkB = 0;

        foreach ( (array) $rows as $r ) {
            $v       = isset( $r['variant'] ) ? (string) $r['variant'] : '';
            $t       = isset( $r['event_type'] ) ? (string) $r['event_type'] : '';
            $c       = isset( $r['c'] ) ? (int) $r['c'] : 0;
            $revenue = isset( $r['revenue'] ) ? (float) $r['revenue'] : 0.0;

            if ( $v === 'A' && $t === 'impression' ) {
                $impA = $c;
            } elseif ( $v === 'A' && $t === 'purchase' ) {
                $clkA = $c;
                $revA = $revenue;
            } elseif ( $v === 'B' && $t === 'impression' ) {
                $impB = $c;
            } elseif ( $v === 'B' && $t === 'purchase' ) {
                $clkB = $c;
                $revB = $revenue;
            }
        }
    } else {
        $stats_resp = abtestkit_handle_stats( $request );
        $stats = $stats_resp instanceof WP_REST_Response
            ? $stats_resp->get_data()
            : (array) $stats_resp;

        $impA = (int) ( $stats['A']['impressions'] ?? 0 );
        $clkA = (int) ( $stats['A']['clicks'] ?? 0 );
        $impB = (int) ( $stats['B']['impressions'] ?? 0 );
        $clkB = (int) ( $stats['B']['clicks'] ?? 0 );
    }

    // Cache evaluation for this exact stats state (60s)
    $eval_cache_key = sprintf(
        'abtestkit_eval:%d:%s:%d:%d:%d:%d:%0.2f:%0.2f:%s:%d:%d',
        (int) $post_id,
        (string) $abTestId,
        $impA,
        $clkA,
        $impB,
        $clkB,
        (float) $revA,
        (float) $revB,
        (string) $decisionMode,
        (int) $minImpressions,
        (int) $minConversions
    );
    $cached_eval = wp_cache_get( $eval_cache_key, 'abtestkit_eval' );
    if ( false !== $cached_eval ) {
        return rest_ensure_response( $cached_eval );
    }

    // Early exits
    if ( $impA === 0 && $impB === 0 ) {
        $out = [ 'probA' => 0.5, 'probB' => 0.5, 'winner' => '', 'message' => 'No impressions recorded yet.' ];
        wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
        return rest_ensure_response( $out );
    }
    if ( $impA === 0 || $impB === 0 ) {
        $out = [ 'probA' => 0.5, 'probB' => 0.5, 'winner' => '', 'message' => 'Only one variant has impressions — test needs more data.' ];
        wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
        return rest_ensure_response( $out );
    }

    $totalImpressions = $impA + $impB;
    $totalConversions = $clkA + $clkB;

    // Auto mode gates
    if ( $decisionMode !== 'manual' ) {
        if ( $totalImpressions < (int) $minImpressions ) {
            $out = [
                'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
                'message' => sprintf( 'Not enough data yet — %d/%d total impressions.', $totalImpressions, (int) $minImpressions ),
            ];
            wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
            return rest_ensure_response( $out );
        }

        if ( $totalConversions < (int) $minConversions ) {
            $out = [
                'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
                'message' => sprintf( 'Not enough conversions yet — %d/%d total conversions.', $totalConversions, (int) $minConversions ),
            ];
            wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
            return rest_ensure_response( $out );
        }
    }

    // Bayesian sampling
    $numSamples = 8000;
    $countA = 0;
    $diffs  = [];

    if ( $conversionEvent === 'purchase' ) {
        for ( $i = 0; $i < $numSamples; $i++ ) {
            $sampA = abtestkit_sample_rpv( $impA, $clkA, $revA );
            $sampB = abtestkit_sample_rpv( $impB, $clkB, $revB );

            if ( $sampA > $sampB ) {
                $countA++;
            }

            $diffs[] = $sampA - $sampB;
        }
    } else {
        $priorN = 2;
        $alphaA = $priorN / 2 + $clkA;
        $betaA  = $priorN / 2 + max( 0, $impA - $clkA );
        $alphaB = $priorN / 2 + $clkB;
        $betaB  = $priorN / 2 + max( 0, $impB - $clkB );

        for ( $i = 0; $i < $numSamples; $i++ ) {
            $sampA = abtestkit_sample_beta( $alphaA, $betaA );
            $sampB = abtestkit_sample_beta( $alphaB, $betaB );

            if ( $sampA > $sampB ) {
                $countA++;
            }

            $diffs[] = $sampA - $sampB;
        }
    }

    $probA = $countA / $numSamples;
    $probB = 1 - $probA;

    // Confidence level by rule:
    // - Fast (25 impressions / 3 conversions): 90%
    // - Balanced + Precise: 95%
    $confidence = 0.95;
    if ( $decisionMode !== 'manual' ) {
        // Prefer the saved rule label if present, otherwise infer from thresholds.
        if (
            ( isset( $decisionRule ) && $decisionRule === 'fast' )
            || ( (int) $minImpressions === 25 && (int) $minConversions === 3 )
        ) {
            $confidence = 0.90;
        }
    }

    sort( $diffs );
    $tail   = ( 1 - $confidence ) / 2; // e.g. 0.05 for 90%, 0.025 for 95%
    $ciLower = $diffs[ (int) ( $tail * $numSamples ) ];
    $ciUpper = $diffs[ (int) ( ( 1 - $tail ) * $numSamples ) ];

    // Winner decision (never in manual mode)
    $winner = '';
    if ( $decisionMode !== 'manual' ) {
        if ( $probA > $confidence && $ciLower > 0 ) {
            $winner = 'A';
        } elseif ( $probB > $confidence && $ciUpper < 0 ) {
            $winner = 'B';
        }
    }

    $result = [
        'probA'   => round( $probA, 4 ),
        'probB'   => round( $probB, 4 ),
        'ciLower' => round( $ciLower, 4 ),
        'ciUpper' => round( $ciUpper, 4 ),
        'winner'  => $winner
    ];

    wp_cache_set( $eval_cache_key, $result, 'abtestkit_eval', 60 );
    return rest_ensure_response( $result );
}

/**
 * Handler for /reset — removes events for given post, index, and abTestId only.
 */
function abtestkit_handle_reset( WP_REST_Request $request ) {
    global $wpdb;

    $body     = json_decode( $request->get_body(), true );
    $post_id  = absint( $body['post_id'] ?? 0 );
    $abTestId = abtestkit_sanitize_test_id( $body['abTestId'] ?? '' );

    if ( empty( $post_id ) || empty( $abTestId ) ) {
        return rest_ensure_response( [
            'success' => false,
            'error'   => 'Missing post_id or abTestId'
        ] );
    }

    // Admin action: safe, parameterized delete scoped to this post/test.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
    $deleted = $wpdb->delete(
        ABTESTKIT_EVENTS_TABLE,
        [
            'post_id'    => $post_id,
            'ab_test_id' => $abTestId,
        ],
        [ '%d', '%s' ]
    );
    // Invalidate stats cache for this post/test.
    wp_cache_delete( "stats:$post_id:$abTestId", 'abtestkit_stats' );
    wp_cache_delete( "stats_multi:$post_id", 'abtestkit_stats' );

    return rest_ensure_response( [ 'success' => true, 'deleted' => $deleted ] );
}

/**
 * Enqueue block-editor scripts: ab-sidebar.js (plugin sidebar) + editor.js (inline-lock HOC).
 */
add_action( 'enqueue_block_editor_assets', function() {

    // Only on post editor screens for a specific post.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
    if ( ! $post_id ) {
        return;
    }

    $post = get_post( $post_id );
    if ( ! $post ) {
        return;
    }

    $post_type = (string) $post->post_type;
    if ( $post_type !== 'post' && $post_type !== 'page' ) {
        return;
    }

    // Detect Version B states used by your plugin.
    $is_shadow    = (int) get_post_meta( $post_id, '_abtestkit_shadow', true ) === 1;
    $is_existingB = (int) get_post_meta( $post_id, '_abtestkit_variant_in_use', true ) === 1;

    if ( ! $is_shadow && ! $is_existingB ) {
        return;
    }

    $type_label = ( $post_type === 'page' ) ? 'Page' : 'Post';

    // Linked Version A ID differs depending on how B was created.
    $control_id = 0;
    if ( $is_shadow ) {
        $control_id = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
    } elseif ( $is_existingB ) {
        $control_id = (int) get_post_meta( $post_id, '_abtestkit_variant_of', true );
    }

    $msg  = 'abtestkit: You are editing Version B (' . $type_label . '). Make your changes, then Publish/Save and close this tab to continue creating your test.';

    // Show a native Gutenberg notice (reliable in block editor).
    $js = "(function(){\n"
        . "  if(!window.wp || !wp.data || !wp.data.dispatch){ return; }\n"
        . "  var msg = " . wp_json_encode( $msg ) . ";\n"
        . "  try {\n"
        . "    wp.data.dispatch('core/notices').createNotice('warning', msg, { isDismissible: true });\n"
        . "  } catch(e) {}\n"
        . "})();\n";

    // wp-edit-post is loaded on the block editor screen.
    wp_add_inline_script( 'wp-edit-post', $js, 'after' );
} );

// ─────────────────────────────────────────────────────────────────────────────
// Admin opt-in notice (shown until accepted/declined)
// ─────────────────────────────────────────────────────────────────────────────
add_action( 'admin_notices', function () {

	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	// Only show on Plugins screen + abtestkit admin pages (keeps it “little”).
	$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
	if ( $screen ) {
		$allowed = [
			'plugins',
			'toplevel_page_abtestkit-dashboard',
			'abtestkit_page_abtestkit-dashboard',
			'admin_page_abtestkit-pt-wizard',
			'admin_page_abtestkit-get-started',
		];
		if ( ! in_array( (string) $screen->id, $allowed, true ) ) {
			return;
		}
	}

	// If the user has already decided yes or no, stop showing the banner.
	if ( abtestkit_get_telemetry_decision() !== '' ) {
		return;
	}

	$yes_url = wp_nonce_url(
		admin_url( 'admin-post.php?action=abtestkit_telemetry_optin&v=1' ),
		'abtestkit_optin'
	);
	$no_url  = wp_nonce_url(
		admin_url( 'admin-post.php?action=abtestkit_telemetry_optin&v=0' ),
		'abtestkit_optin'
	);

    printf(
        '<div class="notice notice-info is-dismissible">
            <p style="display:flex;align-items:center;justify-content:space-between;margin:8px 0;">
                <span><strong>%1$s</strong>: %2$s</span>
                <span style="display:flex;gap:8px;">
                    <a class="button button-primary" href="%3$s">%4$s</a>
                    <a class="button" href="%5$s">%6$s</a>
                </span>
            </p>
        </div>',
        esc_html__( 'abtestkit', 'abtestkit' ),
        esc_html__( 'Help abtestkit improve by sending anonymous usage data.', 'abtestkit' ),
        esc_url( $yes_url ),
        esc_html__( 'Yes, happy to help', 'abtestkit' ),
        esc_url( $no_url ),
        esc_html__( 'No thanks', 'abtestkit' )
    );
} );

add_action('admin_post_abtestkit_telemetry_optin', function () {
    if ( ! current_user_can('manage_options') ) wp_die('forbidden');
    check_admin_referer('abtestkit_optin');
    $v = isset($_GET['v']) && (int) $_GET['v'] === 1;
    abtestkit_set_telemetry_optin( (bool) $v );
    wp_safe_redirect( remove_query_arg( ['action','v','_wpnonce'], wp_get_referer() ?: admin_url() ) );
    exit;
});

/**
 * Enqueue frontend tracking script on single posts (and inject live variants directly from saved block content).
 */
add_action('wp_enqueue_scripts', function () {


// Load on single products/pages + Woo shop, category, tag, archives
    $is_woo_catalog = false;

    // Only call WooCommerce conditionals if WooCommerce is active.
    if ( function_exists( 'is_shop' ) ) {
        $is_woo_catalog =
            is_shop()
            || ( function_exists( 'is_product_category' ) && is_product_category() )
            || ( function_exists( 'is_product_tag' ) && is_product_tag() )
            || ( function_exists( 'is_product_taxonomy' ) && is_product_taxonomy() );
    }

    // Bail early unless we're on a single post/page OR a Woo shop/category/tag/tax page.
    if ( ! is_singular() && ! $is_woo_catalog ) {
        return;
    }

    if ( abtestkit_is_exempt_viewer() ) return;

    $plugin_dir = plugin_dir_url(__FILE__);

    wp_enqueue_script(
        'abtestkit-frontend',
        $plugin_dir . 'assets/js/frontend.js',
        [],
        filemtime(plugin_dir_path(__FILE__) . 'assets/js/frontend.js'),
        true
    );

    $post_id = get_the_ID();

    // Parse the saved block tree so we can mirror test payloads into JS
    $content = get_post_field('post_content', $post_id);
    $blocks  = parse_blocks($content);

    // Base config sent to the browser (WP REST + HMAC fallback)
    $frontendConfig = [
        'postId'  => $post_id,
        'index'   => 0,
        'nonce'   => wp_create_nonce('wp_rest'),
        'restUrl' => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
    ];

    // If this page is part of a full-page/product test, expose the REAL request state to JS.
    list( $pt, $pt_role ) = abtestkit_pt_find_by_post( (int) $post_id );
    if ( $pt && isset( $pt['id'] ) ) {

        $variant_letter    = '';
        $request_is_https  = abtestkit_request_is_real_https();
        $protocol_label    = $request_is_https ? 'https' : 'http';
        $no_track          = ! $request_is_https;
        $http_excluded     = ! $request_is_https;

        // 1) First choice: the assignment already decided earlier in this request.
        if (
            isset( $GLOBALS['abtestkit_current_pt_assignment'] )
            && is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
        ) {
            $ctx      = $GLOBALS['abtestkit_current_pt_assignment'];
            $ctx_test = $ctx['test'] ?? null;
            $ctx_id   = ( is_array( $ctx_test ) && isset( $ctx_test['id'] ) ) ? (string) $ctx_test['id'] : '';

            if ( $ctx_id !== '' && $ctx_id === (string) $pt['id'] ) {
                $ctx_variant = isset( $ctx['variant'] ) ? (string) $ctx['variant'] : '';
                if ( $ctx_variant === 'A' || $ctx_variant === 'B' ) {
                    $variant_letter = $ctx_variant;
                }

                if ( isset( $ctx['protocol'] ) ) {
                    $protocol_label = (string) $ctx['protocol'];
                }

                if ( isset( $ctx['no_track'] ) ) {
                    $no_track = (bool) $ctx['no_track'];
                }

                if ( $protocol_label === 'http' ) {
                    $http_excluded = true;
                }
            }
        }

        // 2) Fallback for product tests.
        if ( $variant_letter === '' && isset( $pt['kind'] ) && $pt['kind'] === 'product' ) {
            list( $prod_test, $prod_variant ) = abtestkit_get_active_product_test_for_product( (int) $post_id );
            if ( $prod_test && isset( $prod_test['id'] ) && (string) $prod_test['id'] === (string) $pt['id'] ) {
                if ( $prod_variant === 'A' || $prod_variant === 'B' ) {
                    $variant_letter = $prod_variant;
                }
            }
        }

        // 3) Final fallback.
        if ( $variant_letter === '' ) {
            if ( ! $request_is_https ) {
                $variant_letter = 'A';
            } else {
                $variant_letter = ( $pt_role === 'control' ) ? 'A' : ( ( $pt_role === 'variant' ) ? 'B' : '' );
            }
        }

        if ( $variant_letter === 'A' || $variant_letter === 'B' ) {

            $links = [];
            if ( ! empty( $pt['links'] ) && is_array( $pt['links'] ) ) {
                $links = array_values(
                    array_map( 'strval', $pt['links'] )
                );
            }

            $frontendConfig['__pageTest'] = [
                'id'           => (string) $pt['id'],
                'variant'      => $variant_letter,
                'goal'         => isset( $pt['goal'] ) ? (string) $pt['goal'] : '',
                'links'        => $links,
                'protocol'     => $protocol_label,
                'noTrack'      => $no_track,
                'httpExcluded' => $http_excluded,
            ];
        }
    }

    // On Woo product listing views (shop, category, tag, taxonomy archives),
    // surface any active PRODUCT tests for the products in the main loop so
    // frontend JS can attribute add_to_cart events from cards correctly.
    if (
        function_exists( 'is_shop' )
        && function_exists( 'wc_get_product' )
        && ( is_shop() || is_product_category() || is_product_tag() || is_product_taxonomy() )
    ) {
        global $wp_query;

        if ( $wp_query && ! empty( $wp_query->posts ) && is_array( $wp_query->posts ) ) {
            $productTests = [];

            foreach ( $wp_query->posts as $loop_post ) {
                $product_id = 0;

                if ( is_object( $loop_post ) && isset( $loop_post->ID ) ) {
                    $product_id = (int) $loop_post->ID;
                } elseif ( is_numeric( $loop_post ) ) {
                    $product_id = (int) $loop_post;
                }

                if ( $product_id <= 0 ) {
                    continue;
                }

                // Ask the existing helper which PRODUCT test (if any) applies to this product
                // and which variant (A/B) this viewer is assigned to.
                [ $prod_test, $prod_variant ] = abtestkit_get_active_product_test_for_product( $product_id );

                if ( ! $prod_test || ! isset( $prod_test['id'] ) ) {
                    continue;
                }

                $goal = isset( $prod_test['goal'] ) ? (string) $prod_test['goal'] : '';

                $productTests[ (string) $product_id ] = [
                    'id'      => (string) $prod_test['id'],
                    'variant' => ( $prod_variant === 'B' ? 'B' : 'A' ),
                    'goal'    => $goal,
                ];
            }

            if ( ! empty( $productTests ) ) {
                $frontendConfig['__productTests'] = $productTests;
            }
        }
    }

    // For “button converts other tests” relations
    $clickTargetMap = [];

    $extract_variants = function(array $blocks) use (&$extract_variants, &$frontendConfig, &$clickTargetMap) {
        foreach ($blocks as $block) {
            if (!is_array($block) || empty($block['blockName'])) continue;

            $attrs            = $block['attrs'] ?? [];
            $abTestId         = $attrs['abTestId'] ?? null;
            $abTestVariants   = $attrs['abTestVariants'] ?? null;
            $conversionFrom   = $attrs['conversionFrom'] ?? [];

            // If the block lists tests it “converts”, remember that mapping
            foreach ((array) $conversionFrom as $sourceId) {
                if (!isset($clickTargetMap[$sourceId])) $clickTargetMap[$sourceId] = [];
                $clickTargetMap[$sourceId][] = $abTestId;
            }

            // Only build a payload when this block actually hosts a test
            if ($abTestId && is_array($abTestVariants) && isset($abTestVariants[$abTestId])) {
                // Prefer values already stored inside abTestVariants[abTestId] (they persist across reloads),
                // then fall back to the loose block attributes, then defaults.
                $stored = $abTestVariants[$abTestId];

                // Goal: prefer stored → then loose attribute → default
                $goal = $stored['conversionGoalType'] ?? ($attrs['conversionGoalType'] ?? 'button');
                // Allow clicks, forms and Woo "add_to_cart" goals (treated like form submit on the frontend)
                $goal = in_array( $goal, [ 'button', 'link', 'form', 'add_to_cart' ], true ) ? $goal : 'button';

                // Links: prefer stored → then loose attribute → []
                $links = [];
                if (!empty($stored['conversionLinks']) && is_array($stored['conversionLinks'])) {
                    $links = array_values(array_filter(array_map('strval', $stored['conversionLinks'])));
                } elseif (!empty($attrs['conversionLinks']) && is_array($attrs['conversionLinks'])) {
                    $links = array_values(array_filter(array_map('strval', $attrs['conversionLinks'])));
                }

                // Attach canonical values back to the per-test object
                $abTestVariants[$abTestId]['conversionGoalType'] = $goal;
                $abTestVariants[$abTestId]['conversionLinks']    = $links;

                // “This button converts these tests”: prefer loose attr (UI is source of truth), else stored
                $from = [];
                if (!empty($attrs['conversionFrom']) && is_array($attrs['conversionFrom'])) {
                    $from = array_values(array_unique(array_filter(array_map('strval', $attrs['conversionFrom']))));
                } elseif (!empty($stored['conversionFrom']) && is_array($stored['conversionFrom'])) {
                    $from = array_values(array_unique(array_filter(array_map('strval', $stored['conversionFrom']))));
                }
                $abTestVariants[$abTestId]['conversionFrom'] = $from;

                // Running flag: unlocked tests are “running”
                $locked = $abTestVariants[$abTestId]['locked'] ?? true;
                $abTestVariants[$abTestId]['running'] = !$locked;

                // Merge grouped tests from whatever we calculated plus anything already saved
                $storedGrouped     = $abTestVariants[$abTestId]['groupedAbTests'] ?? [];
                $calculatedGrouped = $clickTargetMap[$abTestId] ?? [];
                $mergedGrouped     = array_values(array_unique(array_merge($storedGrouped, $calculatedGrouped)));

                $abTestVariants[$abTestId]['groupedAbTests'] = $mergedGrouped;
                // Keep the earlier-resolved $from (attrs preferred, else stored), do not overwrite it here.
                $abTestVariants[$abTestId]['conversionFrom'] = $from;


                // Finally, expose this test on window.abTestConfig under its ID
                $frontendConfig[$abTestId] = $abTestVariants[$abTestId];
            }

            // Recurse
            if (!empty($block['innerBlocks'])) {
                $extract_variants($block['innerBlocks']);
            }
        }
    };

    $extract_variants($blocks);

    wp_localize_script('abtestkit-frontend', 'abTestConfig', $frontendConfig);
});


register_activation_hook(__FILE__, function () {
    abtestkit_create_event_table();

    if ( function_exists( 'abtestkit_telemetry_schedule_heartbeat' ) ) {
        abtestkit_telemetry_schedule_heartbeat();
    }
    if ( ! get_option( ABTESTKIT_TELEMETRY_INSTALL_OPTION ) ) {
        update_option( ABTESTKIT_TELEMETRY_INSTALL_OPTION, time() );
    }
    if ( abtestkit_is_telemetry_opted_in() && ! abtestkit_flag_is_set('installed_sent') ) {
        abtestkit_send_telemetry( 'plugin_installed', [
            'installed_at' => (int) get_option( ABTESTKIT_TELEMETRY_INSTALL_OPTION ),
        ] );
        abtestkit_mark_flag('installed_sent');
    }
});

// Deactivation: remove rate-limit transients and flush page/object caches
register_deactivation_hook(__FILE__, 'abtestkit_on_deactivate');
function abtestkit_on_deactivate() {
    if ( function_exists( 'abtestkit_telemetry_unschedule_heartbeat' ) ) {
        abtestkit_telemetry_unschedule_heartbeat();
    }

    global $wpdb;

    // Maintenance: clear this plugin’s rate-limit transients on deactivation.
    $patterns = [
        '_transient_abtestkit_trk_%',
        '_transient_timeout_abtestkit_trk_%',
    ];

    foreach ( $patterns as $pattern ) {
        $like      = $wpdb->esc_like( $pattern );
        $cache_key = 'abtestkit_deactivate_option_names_' . md5( $like );
        $option_names = wp_cache_get( $cache_key, 'abtestkit' );

        if ( false === $option_names ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $option_names = $wpdb->get_col(
                $wpdb->prepare(
                    "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
                    $like
                )
            );

            wp_cache_set( $cache_key, $option_names, 'abtestkit', MINUTE_IN_SECONDS );
        }

        if ( empty( $option_names ) || ! is_array( $option_names ) ) {
            continue;
        }

        foreach ( $option_names as $option_name ) {
            $option_name = (string) $option_name;

            if ( strpos( $option_name, '_transient_timeout_' ) === 0 ) {
                delete_transient( substr( $option_name, strlen( '_transient_timeout_' ) ) );
            } elseif ( strpos( $option_name, '_transient_' ) === 0 ) {
                delete_transient( substr( $option_name, strlen( '_transient_' ) ) );
            }
        }
    }


    // Best-effort cache flushers (object cache + common caching plugins)
    if (function_exists('wp_cache_flush')) { wp_cache_flush(); }
    if (function_exists('w3tc_flush_all')) { w3tc_flush_all(); }
    if (function_exists('rocket_clean_domain')) { rocket_clean_domain(); }
    if (function_exists('autoptimize_flush_cache')) { autoptimize_flush_cache(); }
    // Purge LiteSpeed Cache via documented external hook.
    // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    if (class_exists('LiteSpeed_Cache')) { do_action('litespeed_purge_all'); }
    // WP Super Cache
    if (function_exists('wp_cache_clear_cache')) { wp_cache_clear_cache(); }
}

// ---  Helper to strip AB attributes from block trees ---
    function abtestkit_strip_ab_attrs_from_blocks(array $blocks, &$changed = false) {
        $out = [];
        foreach ($blocks as $b) {
         if (!is_array($b)) { $out[] = $b; continue; }

            // Remove all AB-related attributes the plugin added
         $attrs = isset($b['attrs']) && is_array($b['attrs']) ? $b['attrs'] : [];
         $keys  = [
            'abTestEnabled','abTestVariants','abTestId','abTestRunning','abTestWinner',
            'abTestResultsViewed','conversionFrom','abTestLastUnlocked','abTestStartedAt',
            'abTestFinishedAt','abSync','abGroupKey'
        ];
        $touched = false;
        foreach ($keys as $k) {
            if (isset($attrs[$k])) { unset($attrs[$k]); $touched = true; }
        }
        if ($touched) { $changed = true; }
        $b['attrs'] = $attrs;

        // Recurse innerBlocks
        if (!empty($b['innerBlocks']) && is_array($b['innerBlocks'])) {
            $b['innerBlocks'] = abtestkit_strip_ab_attrs_from_blocks($b['innerBlocks'], $changed);
        }
        $out[] = $b;
    }
    return $out;
}

// ─────────────────────────────────────────────────────────────────────────────
// abtestkit – Page Duplicator A/B Tests (MVP Dashboard)
// ─────────────────────────────────────────────────────────────────────────────

if ( ! defined( 'ABTESTKIT_PAGE_TESTS_OPTION' ) ) {
    define( 'ABTESTKIT_PAGE_TESTS_OPTION', 'abtestkit_page_tests' );
}

/**
 * Registry helpers (stored as a single option).
 * Test shape: id, title, control_id, variant_id, status(paused|running|complete|draft),
 * split (0..100 => % to B), cookie_ttl_days, started_at, finished_at
 */
function abtestkit_pt_all() : array {
    $tests = get_option( ABTESTKIT_PAGE_TESTS_OPTION, [] );

    if ( ! is_array( $tests ) ) {
        return [];
    }

    $changed = false;

    foreach ( $tests as &$t ) {
        if ( ! is_array( $t ) || empty( $t['id'] ) ) {
            continue;
        }

        // Normalise status – default to "paused"
        if ( empty( $t['status'] ) ) {
            $t['status'] = 'paused';
            $changed     = true;
        } else {
            $valid_statuses = [ 'paused', 'running', 'winner', 'complete', 'draft' ];
            if ( ! in_array( $t['status'], $valid_statuses, true ) ) {
                $t['status'] = 'paused';
                $changed     = true;
            }
        }

        // If a test has a finished_at timestamp but is still "running", mark it complete.
        $finished_at = isset( $t['finished_at'] ) ? (int) $t['finished_at'] : 0;
        if ( $finished_at > 0 && $t['status'] === 'running' ) {
            $t['status'] = 'complete';
            $changed     = true;
        }

        // Normalise pause bookkeeping fields so older installs don't break.
        $paused_at = isset( $t['paused_at'] ) ? (int) $t['paused_at'] : 0;
        if ( $paused_at < 0 ) { $paused_at = 0; }
        if ( ! isset( $t['paused_at'] ) || (int) $t['paused_at'] !== $paused_at ) {
            $t['paused_at'] = $paused_at;
            $changed        = true;
        }

        $paused_total = isset( $t['paused_total'] ) ? (int) $t['paused_total'] : 0;
        if ( $paused_total < 0 ) { $paused_total = 0; }
        if ( ! isset( $t['paused_total'] ) || (int) $t['paused_total'] !== $paused_total ) {
            $t['paused_total'] = $paused_total;
            $changed           = true;
        }

        $started_at = isset( $t['started_at'] ) ? (int) $t['started_at'] : 0;
        if ( $started_at < 0 ) { $started_at = 0; }
        if ( ! isset( $t['started_at'] ) || (int) $t['started_at'] !== $started_at ) {
            $t['started_at'] = $started_at;
            $changed         = true;
        }

        // Safety: if a "running" test has lost both page references, pause it.
        if ( $t['status'] === 'running' ) {
            $control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
            $variant_id = isset( $t['variant_id'] ) ? (int) $t['variant_id'] : 0;

            if ( $control_id <= 0 && $variant_id <= 0 ) {
                $t['status'] = 'paused';
                $changed     = true;
            }
        }

        // Infer missing test kind for older installs/tests.
        if ( empty( $t['kind'] ) ) {
            $control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
            $control_type = $control_id > 0 ? get_post_type( $control_id ) : '';

            if ( $control_type === 'product' ) {
                $t['kind'] = 'product';
                $changed   = true;
            } elseif ( $control_type === 'post' ) {
                $t['kind'] = 'post';
                $changed   = true;
            } else {
                $t['kind'] = 'page';
                $changed   = true;
            }
        }

        // Decision settings (new) — defaults for older installs/tests.
        if ( empty( $t['decision_rule'] ) ) {
            $t['decision_rule'] = 'balanced';
            $changed            = true;
        }
        if ( empty( $t['decision_mode'] ) ) {
            // If rule is manual, mode must be manual.
            $t['decision_mode'] = ( (string) $t['decision_rule'] === 'manual' ) ? 'manual' : 'auto';
            $changed            = true;
        }

        // Normalise thresholds.
        $rule = (string) $t['decision_rule'];
        $mode = (string) $t['decision_mode'];

        // Manual mode: never auto-declare winner => allow 0/0 thresholds.
        if ( $mode === 'manual' || $rule === 'manual' ) {
            if ( (int) ( $t['min_impressions'] ?? -1 ) !== 0 ) {
                $t['min_impressions'] = 0;
                $changed              = true;
            }
            if ( (int) ( $t['min_conversions'] ?? -1 ) !== 0 ) {
                $t['min_conversions'] = 0;
                $changed              = true;
            }
        } else {
            // Auto mode: only allow preset thresholds.
            $allowed_mi = [ 25, 50, 75 ];
            $allowed_mc = [ 3, 5, 10 ];

            $mi = isset( $t['min_impressions'] ) ? (int) $t['min_impressions'] : 0;
            $mc = isset( $t['min_conversions'] ) ? (int) $t['min_conversions'] : 0;

            if ( ! in_array( $mi, $allowed_mi, true ) ) {
                $t['min_impressions'] = 50;
                $changed              = true;
            }
            if ( ! in_array( $mc, $allowed_mc, true ) ) {
                $t['min_conversions'] = 5;
                $changed              = true;
            }

            // Keep rule presets consistent even if data got edited.
            if ( $rule === 'fast' )    { $t['min_impressions'] = 25; $t['min_conversions'] = 3; $changed = true; }
            if ( $rule === 'balanced' ){ $t['min_impressions'] = 50; $t['min_conversions'] = 5; $changed = true; }
            if ( $rule === 'precise' ) { $t['min_impressions'] = 75; $t['min_conversions'] = 10; $changed = true; }
        }
    }
    unset( $t ); // break reference

    if ( $changed ) {
        // Persist the normalised test list back to the option so stale
        // "running" tests stop blocking new ones.
        update_option( ABTESTKIT_PAGE_TESTS_OPTION, $tests, false );
    }

    return $tests;
}

function abtestkit_pt_save( array $tests ) {
    update_option( ABTESTKIT_PAGE_TESTS_OPTION, $tests, false );
}
function abtestkit_pt_get( string $id ) : ?array {
    foreach ( abtestkit_pt_all() as $t ) {
        if ( isset( $t['id'] ) && $t['id'] === $id ) return $t;
    }
    return null;
}
function abtestkit_pt_put( array $test ) {
    $tests = abtestkit_pt_all();
    $found = false;
    foreach ( $tests as &$t ) {
        if ( $t['id'] === $test['id'] ) { $t = $test; $found = true; break; }
    }
    if ( ! $found ) $tests[] = $test;
    abtestkit_pt_save( $tests );

    if ( ! empty( $test['id'] ) ) {
        abtestkit_pt_flush_test_caches( (string) $test['id'] );
    }
}
function abtestkit_pt_delete( string $id ) {
    $all = abtestkit_pt_all();

    // Find the test before deleting it (so we can clean up any "existing B" SEO markers).
    $test = null;
    foreach ( $all as $t ) {
        if ( ( $t['id'] ?? '' ) === $id ) {
            $test = $t;
            break;
        }
    }

    if ( is_array( $test ) ) {
        $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;

        // Only unmark existing variants (never touch shadow duplicates).
        if ( $variant_id > 0 && ! abtestkit_is_shadow_variant( $variant_id ) ) {
            abtestkit_pt_unmark_existing_variant_in_use( $variant_id, (string) $id );
        }
    }

    $tests = array_values(
        array_filter(
            $all,
            fn( $t ) => ( $t['id'] ?? '' ) !== $id
        )
    );

    if ( is_array( $test ) && ! empty( $test['id'] ) ) {
        abtestkit_pt_flush_test_caches( (string) $test['id'] );
    }

    abtestkit_pt_save( $tests );
}

/**
 * Wizard safety: track the last "duplicate" created for a user+control so we don't
 * accidentally create multiple Version B posts (especially for products).
 */
function abtestkit_pt_last_duplicate_key( int $control_id, int $user_id ) : string {
    return 'abtestkit_pt_last_dup_' . (int) $user_id . '_' . (int) $control_id;
}

/** True if a post ID is already the variant_id of any stored test (any status). */
function abtestkit_pt_variant_id_in_any_test( int $variant_id ) : bool {
    $variant_id = (int) $variant_id;
    if ( $variant_id <= 0 ) return false;

    foreach ( abtestkit_pt_all() as $t ) {
        if ( (int) ( $t['variant_id'] ?? 0 ) === $variant_id ) {
            return true;
        }
    }
    return false;
}

/**
 * Return the last duplicate for this user+control if it still looks valid and isn't already in use.
 * Also validates shadow relationship for products.
 */
function abtestkit_pt_get_last_duplicate_for_user( int $control_id, int $user_id ) : int {
    $control_id = (int) $control_id;
    $user_id    = (int) $user_id;
    if ( $control_id <= 0 || $user_id <= 0 ) return 0;

    $key = abtestkit_pt_last_duplicate_key( $control_id, $user_id );
    $pid = (int) get_transient( $key );
    if ( $pid <= 0 ) return 0;

    $p = get_post( $pid );
    if ( ! $p ) return 0;

    $control_type = get_post_type( $control_id );
    if ( ! $control_type || $p->post_type !== $control_type ) return 0;

    // Don't reuse if it is already used by any test.
    if ( abtestkit_pt_variant_id_in_any_test( $pid ) ) return 0;

    // Product safety: must be a shadow of this control.
    if ( $control_type === 'product' ) {
        if ( ! function_exists( 'abtestkit_is_shadow_product' ) || ! abtestkit_is_shadow_product( $pid ) ) return 0;
        $shadow_of = (int) get_post_meta( $pid, '_abtestkit_shadow_of', true );
        if ( $shadow_of !== $control_id ) return 0;
    }

    return $pid;
}

function abtestkit_pt_set_last_duplicate_for_user( int $control_id, int $variant_id, int $user_id, int $ttl_seconds = 1800 ) : void {
    $control_id = (int) $control_id;
    $variant_id = (int) $variant_id;
    $user_id    = (int) $user_id;

    if ( $control_id <= 0 || $variant_id <= 0 || $user_id <= 0 ) return;

    $ttl_seconds = max( 60, (int) $ttl_seconds );
    set_transient( abtestkit_pt_last_duplicate_key( $control_id, $user_id ), $variant_id, $ttl_seconds );
}

function abtestkit_pt_clear_last_duplicate_for_user( int $control_id, int $user_id ) : void {
    $control_id = (int) $control_id;
    $user_id    = (int) $user_id;

    if ( $control_id <= 0 || $user_id <= 0 ) return;
    delete_transient( abtestkit_pt_last_duplicate_key( $control_id, $user_id ) );
}

/**
 * Find the best RUNNING test by page id.
 * Returns [test, "control"|"variant"].
 * If multiple running tests match, pick the newest (highest started_at).
 */
function abtestkit_pt_find_by_post( int $post_id ) : array {
    $post_id = (int) $post_id;
    if ( $post_id <= 0 ) return [ null, '' ];

    $variant_matches = [];
    $control_matches = [];

    foreach ( abtestkit_pt_all() as $t ) {
        if ( ! is_array( $t ) ) continue;
        if ( ( $t['status'] ?? 'paused' ) !== 'running' ) continue;

        if ( (int) ( $t['variant_id'] ?? 0 ) === $post_id ) {
            $variant_matches[] = $t;
            continue;
        }

        if ( (int) ( $t['control_id'] ?? 0 ) === $post_id ) {
            $control_matches[] = $t;
        }
    }

    // Variant match should win immediately (unique URL for PAGE tests).
    if ( count( $variant_matches ) === 1 ) {
        return [ $variant_matches[0], 'variant' ];
    }
    if ( count( $variant_matches ) > 1 ) {
        if ( ! headers_sent() ) header( 'X-Abtestkit-Conflict: variant-in-multiple-tests' );
        return [ null, '' ];
    }

    // Control match: if multiple, pick newest by started_at.
    if ( empty( $control_matches ) ) return [ null, '' ];

    usort( $control_matches, function( $a, $b ) {
        $sa = (int) ( $a['started_at'] ?? 0 );
        $sb = (int) ( $b['started_at'] ?? 0 );
        if ( $sa !== $sb ) return $sb <=> $sa; // newest first
        return strcmp( (string) ( $b['id'] ?? '' ), (string) ( $a['id'] ?? '' ) );
    } );

    return [ $control_matches[0], 'control' ];
}

/**
 * Like abtestkit_pt_find_by_post() but allows resolving tests that are paused/complete/etc.
 * Used for preview forcing (?abtestkit_preview=1&abtestkit_force=A|B).
 */
function abtestkit_pt_find_by_post_any_status( int $post_id ) : array {
    $post_id = (int) $post_id;
    if ( $post_id <= 0 ) return [ null, '' ];

    $variant_matches = [];
    $control_matches = [];

    foreach ( abtestkit_pt_all() as $t ) {
        if ( ! is_array( $t ) ) continue;

        // Skip drafts; allow paused/running/complete
        if ( ( $t['status'] ?? 'paused' ) === 'draft' ) continue;

        if ( (int) ( $t['variant_id'] ?? 0 ) === $post_id ) {
            $variant_matches[] = $t;
            continue;
        }

        if ( (int) ( $t['control_id'] ?? 0 ) === $post_id ) {
            $control_matches[] = $t;
        }
    }

    // Variant match wins if unique.
    if ( count( $variant_matches ) === 1 ) {
        return [ $variant_matches[0], 'variant' ];
    }
    if ( count( $variant_matches ) > 1 ) {
        if ( ! headers_sent() ) header( 'X-Abtestkit-Conflict: variant-in-multiple-tests' );
        return [ null, '' ];
    }

    if ( empty( $control_matches ) ) return [ null, '' ];

    // Prefer running > paused > complete, then newest started_at.
    usort( $control_matches, function( $a, $b ) {
        $pa = function_exists( 'abtestkit_pt_status_priority' ) ? abtestkit_pt_status_priority( (string) ( $a['status'] ?? '' ) ) : 5;
        $pb = function_exists( 'abtestkit_pt_status_priority' ) ? abtestkit_pt_status_priority( (string) ( $b['status'] ?? '' ) ) : 5;

        if ( $pa !== $pb ) return $pa <=> $pb;

        $sa = (int) ( $a['started_at'] ?? 0 );
        $sb = (int) ( $b['started_at'] ?? 0 );
        if ( $sa !== $sb ) return $sb <=> $sa;

        return strcmp( (string) ( $b['id'] ?? '' ), (string) ( $a['id'] ?? '' ) );
    } );

    return [ $control_matches[0], 'control' ];
}

/** Return IDs of running tests that already use either $control_id or $variant_id (excluding $exclude_id). */
function abtestkit_pt_conflicts_for_pages( int $control_id, int $variant_id = 0, string $exclude_id = '' ) : array {
    $conflicts = [];
    foreach ( abtestkit_pt_all() as $t ) {
        if ( ($t['status'] ?? 'paused') !== 'running' ) continue;
        if ( $exclude_id && isset($t['id']) && $t['id'] === $exclude_id ) continue;

        $uses_control = (int) $t['control_id'] === $control_id || ( $variant_id && (int) $t['control_id'] === $variant_id );
        $uses_variant = (int) $t['variant_id'] === $control_id || ( $variant_id && (int) $t['variant_id'] === $variant_id );
        if ( $uses_control || $uses_variant ) {
            $conflicts[] = (string) ($t['id'] ?? '');
        }
    }
    return array_values( array_filter( array_unique( $conflicts ) ) );
}

/**
 * Integration layer: regenerate CSS/assets for common page builders
 * when we duplicate a page (Version B).
 *
 * @param int $new_id    The ID of the duplicated page.
 * @param int $source_id The original/control page ID.
 */
function abtestkit_regenerate_builder_assets( int $new_id, int $source_id = 0 ) : void {
    $new_id    = (int) $new_id;
    $source_id = (int) $source_id;

    if ( $new_id <= 0 ) {
        return;
    }

    // ─────────────────────────────────────────────────────
    // Elementor
    // ─────────────────────────────────────────────────────
    if ( defined( 'ELEMENTOR_VERSION' ) && class_exists( '\Elementor\Plugin' ) ) {
        try {
            // Clear any old CSS and build fresh CSS for this post
            if ( isset( \Elementor\Plugin::$instance->files_manager )
                && method_exists( \Elementor\Plugin::$instance->files_manager, 'clear_cache' )
            ) {
                \Elementor\Plugin::$instance->files_manager->clear_cache();
            }

            // Ask Elementor to clear generated CSS for the post (external hook).
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
            do_action( 'elementor/css-file/post/clear', $new_id );

            // Newer Elementor has a per-post CSS object.
            if ( class_exists( '\Elementor\Core\Files\CSS\Post' ) ) {
                $css = new \Elementor\Core\Files\CSS\Post( $new_id );
                if ( method_exists( $css, 'update' ) ) {
                    $css->update();
                }
            }
        } catch ( \Throwable $e ) {
            // Fail silently – never break the site if Elementor changes APIs.
        }
    }

    // ─────────────────────────────────────────────────────
    // Breakdance
    // ─────────────────────────────────────────────────────
    if ( function_exists( 'breakdance' ) ) {
        try {
            // Official per-post CSS regeneration.
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
            do_action( 'breakdance_generate_css_for_post', $new_id );

            // Optional: full CSS cache clear (heavier; keep only if needed).
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
            // do_action( 'breakdance_clear_css_cache' );
        } catch ( \Throwable $e ) {
        }
    }

    // ─────────────────────────────────────────────────────
    // Beaver Builder
    // ─────────────────────────────────────────────────────
    if ( class_exists( 'FLBuilderModel' ) ) {
        try {
            if ( method_exists( 'FLBuilderModel', 'delete_asset_cache_for_post' ) ) {
                \FLBuilderModel::delete_asset_cache_for_post( $new_id );
            }
            if ( function_exists( 'fl_builder_flush_caches' ) ) {
                fl_builder_flush_caches();
            }
        } catch ( \Throwable $e ) {
        }
    }

    // ─────────────────────────────────────────────────────
    // Divi
    // ─────────────────────────────────────────────────────
    if ( function_exists( 'et_core_cache_flush' ) ) {
        try {
            et_core_cache_flush();
        } catch ( \Throwable $e ) {
        }
    }

    // ─────────────────────────────────────────────────────
    // Bricks
    // ─────────────────────────────────────────────────────
    if ( defined( 'BRICKS_VERSION' ) ) {
        try {
            // External hook – you or Bricks can hook into this.
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
            do_action( 'bricks/abtestkit/regenerate_css', $new_id, $source_id );
        } catch ( \Throwable $e ) {
        }
    }

    // ─────────────────────────────────────────────────────
    // Oxygen
    // ─────────────────────────────────────────────────────
    if ( function_exists( 'ct_cssbuffer_update' ) ) {
        try {
            ct_cssbuffer_update();
        } catch ( \Throwable $e ) {
        }
    }

    // ─────────────────────────────────────────────────────
    // WPBakery / Visual Composer: shortcode-based
    // ─────────────────────────────────────────────────────
    // No direct API needed, but expose a hook for add-ons.
    // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    do_action( 'abtestkit/after_duplicate/wpbakery', $new_id, $source_id );

    // ─────────────────────────────────────────────────────
    // Generic caches (object cache / page caches)
    // ─────────────────────────────────────────────────────
    if ( function_exists( 'wp_cache_flush' ) ) {
        wp_cache_flush();
    }
}

/** Duplicate a page or product (content + meta + taxonomies). */
function abtestkit_duplicate_post_deep( int $post_id ) : int {
    $orig = get_post( $post_id );
    if ( ! $orig ) {
        return 0;
    }

    // Only allow types we deliberately support in the wizard.
    $allowed_types = [ 'page', 'post', 'product' ];
    if ( ! in_array( $orig->post_type, $allowed_types, true ) ) {
        return 0;
    }

    $is_product = ( $orig->post_type === 'product' );

    $new_postarr = [
        // Keep the original title (we’ll show “Shadow / Variant B” via admin UI instead).
        'post_title'   => $orig->post_title,
        'post_content' => $orig->post_content,

        // Always create a draft clone from the wizard so it's not publicly accessible.
        'post_status'  => 'draft',

        'post_type'    => $orig->post_type,
        'post_author'  => get_current_user_id() ?: $orig->post_author,
        'post_parent'  => $orig->post_parent,
        'menu_order'   => $orig->menu_order,
        'post_excerpt' => $orig->post_excerpt,

        // Keep a distinct slug
        'post_name'    => sanitize_title( $orig->post_name . '-variant-b' ),
    ];


    $new_id = wp_insert_post( wp_slash( $new_postarr ), true );
    if ( is_wp_error( $new_id ) || ! $new_id ) {
        return 0;
    }

    // Copy taxonomies (works for products and pages)
    $taxes = get_object_taxonomies( $orig->post_type );
    foreach ( $taxes as $tx ) {
        $terms = wp_get_object_terms( $post_id, $tx, [ 'fields' => 'ids' ] );
        if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
            wp_set_object_terms( $new_id, $terms, $tx, false );
        }
    }

    // Copy meta (FAST + low memory): do it in SQL (do not load all meta into PHP).
    global $wpdb;

    // Exclude a small, fixed set of meta keys from the fast SQL clone.
    // Keeping placeholders fixed avoids PHPCS false-positives around dynamic placeholder lists.
    $exclude = [
        '_edit_lock',
        '_edit_last',
        '_wp_old_slug',
        $is_product ? '_sku' : '__abtestkit_noop__',
    ];

    // Direct INSERT is intentional here (fast meta clone). Caching is not applicable for writes.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $wpdb->query(
        $wpdb->prepare(
            "
            INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
            SELECT %d, meta_key, meta_value
            FROM {$wpdb->postmeta}
            WHERE post_id = %d
              AND meta_key NOT IN ( %s, %s, %s, %s )
            ",
            (int) $new_id,
            (int) $post_id,
            $exclude[0],
            $exclude[1],
            $exclude[2],
            $exclude[3]
        )
    );

    // ───────────────────────────────────────────────────────────
    // WooCommerce-specific hygiene for Version B products
    // ───────────────────────────────────────────────────────────
    if ( $orig->post_type === 'product' ) {

        // Mark this as a shadow variant product, linked to its real (A) product.
        update_post_meta( $new_id, '_abtestkit_shadow', 1 );
        update_post_meta( $new_id, '_abtestkit_shadow_of', (int) $post_id );

        // Hide from catalog/search so it never appears in loops.
        if ( function_exists( 'wc_get_product' ) ) {
            $product_b = wc_get_product( $new_id );
            if ( $product_b ) {
                $product_b->set_catalog_visibility( 'hidden' ); // not in catalog/search
                $product_b->save();
            }
        }

        // Prevent SKU collisions.
        delete_post_meta( $new_id, '_sku' );

        // Optional: make sure the shadow cannot ever be purchased if somehow linked.
        // (We also enforce this with filters later.)
        update_post_meta( $new_id, '_sold_individually', 'yes' );
    }

    // ───────────────────────────────────────────────────────────
    // hygiene: mark ALL duplicates as "shadow" variants
    // This is used for SEO (noindex), canonical, and sitemap exclusion,
    // even if the test is paused or deleted later.
    // ───────────────────────────────────────────────────────────
    update_post_meta( $new_id, '_abtestkit_shadow', 1 );
    update_post_meta( $new_id, '_abtestkit_shadow_of', (int) $post_id );
    // ───────────────────────────────────────────────────────────
    // Builder compatibility: regenerate CSS/assets on the clone
    // ───────────────────────────────────────────────────────────
    
    abtestkit_regenerate_builder_assets( $new_id, $post_id );

    return $new_id;
}

/**
 * Shadow / Variant products guardrails
 * - Never allow a shadow product to be published
 * - Hide shadow products from the normal Products list
 * - Ensure shadow product URLs are noindex if accessed
 * - Show a strong admin warning banner on edit screens
 */
/**
 * Clear cached admin shadow counts (used for list count tabs).
 *
 * @param string|null $type post type to clear (product/post/page). If null, clears all.
 */
function abtestkit_clear_shadow_counts_cache( $type = null ) {
    $cache_group = 'abtestkit';
    $blog_id     = (int) get_current_blog_id();

    if ( $type ) {
        wp_cache_delete( 'shadow_counts_by_status_' . $type . '_' . $blog_id, $cache_group );
        return;
    }

    foreach ( [ 'product', 'post', 'page' ] as $pt ) {
        wp_cache_delete( 'shadow_counts_by_status_' . $pt . '_' . $blog_id, $cache_group );
    }
}

function abtestkit_is_shadow_product( $post_id ) {
    return (int) get_post_meta( (int) $post_id, '_abtestkit_shadow', true ) === 1;
}


/**
 * Universal SEO hygiene for ALL shadow variants (posts/pages/products):
 * - Always noindex/nofollow
 * - Canonical back to Version A (shadow_of)
 * This applies even if a test is paused, draft, or deleted.
 */
function abtestkit_is_shadow_variant( int $post_id ): bool {
    return (int) get_post_meta( $post_id, '_abtestkit_shadow', true ) === 1;
}

function abtestkit_is_seo_protected_variant( int $post_id ) : bool {
    return abtestkit_is_shadow_variant( $post_id ) || abtestkit_is_existing_variant_in_use( $post_id );
}

function abtestkit_shadow_of( int $post_id ): int {
    return (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
}

// ──────────────────────────────────────────────────────────────
// Variant-in-use markers (for "existing page as Version B")
// ──────────────────────────────────────────────────────────────
function abtestkit_pt_mark_existing_variant_in_use( int $variant_id, int $control_id, string $test_id ) : void {
    if ( $variant_id <= 0 || $control_id <= 0 || $test_id === '' ) return;

    update_post_meta( $variant_id, '_abtestkit_variant_in_use', 1 );
    update_post_meta( $variant_id, '_abtestkit_variant_of', (int) $control_id );
    update_post_meta( $variant_id, '_abtestkit_variant_test_id', (string) $test_id );
}

function abtestkit_pt_unmark_existing_variant_in_use( int $variant_id, string $test_id = '' ) : void {
    if ( $variant_id <= 0 ) return;

    // If we know the test_id, only remove if it matches (safety).
    if ( $test_id !== '' ) {
        $cur = (string) get_post_meta( $variant_id, '_abtestkit_variant_test_id', true );
        if ( $cur !== '' && $cur !== $test_id ) return;
    }

    delete_post_meta( $variant_id, '_abtestkit_variant_in_use' );
    delete_post_meta( $variant_id, '_abtestkit_variant_of' );
    delete_post_meta( $variant_id, '_abtestkit_variant_test_id' );
}

function abtestkit_is_existing_variant_in_use( int $post_id ) : bool {
    return (int) get_post_meta( $post_id, '_abtestkit_variant_in_use', true ) === 1;
}

add_filter( 'wp_robots', function( $robots ) {
    if ( is_admin() ) {
        return $robots;
    }

    if ( ! is_singular() ) {
        return $robots;
    }

    $pid = (int) get_queried_object_id();
    if ( ! $pid ) {
        return $robots;
    }

    if ( abtestkit_is_seo_protected_variant( $pid ) ) {
        $robots['noindex']  = true;
        $robots['nofollow'] = true;
        $robots['noarchive'] = true;
        $robots['nosnippet'] = true;
    }

    return $robots;
}, 20 );

add_filter( 'get_canonical_url', function( $canonical, $post ) {
    if ( is_admin() ) {
        return $canonical;
    }

    $pid = 0;
    if ( $post instanceof WP_Post ) {
        $pid = (int) $post->ID;
    } elseif ( is_numeric( $post ) ) {
        $pid = (int) $post;
    }

    if ( ! $pid ) {
        return $canonical;
    }

    if ( ! abtestkit_is_seo_protected_variant( $pid ) ) {
        return $canonical;
    }

    // Shadow duplicates store A in _abtestkit_shadow_of.
    // Existing pages used as B store A in _abtestkit_variant_of.
    $a_id = abtestkit_is_shadow_variant( $pid )
        ? abtestkit_shadow_of( $pid )
        : (int) get_post_meta( $pid, '_abtestkit_variant_of', true );
    if ( $a_id > 0 ) {
        $a_url = get_permalink( $a_id );
        if ( $a_url ) {
            return $a_url;
        }
    }

    return $canonical;
}, 20, 2 );

// Exclude shadow variants from WordPress core sitemaps.
add_filter( 'wp_sitemaps_posts_query_args', function( $args, $post_type ) {
    if ( empty( $args['meta_query'] ) || ! is_array( $args['meta_query'] ) ) {
        $args['meta_query'] = [];
    }

    $args['meta_query'][] = [
        'relation' => 'AND',
        [
            'key'     => '_abtestkit_shadow',
            'value'   => 1,
            'compare' => '!=',
            'type'    => 'NUMERIC',
        ],
        [
            'key'     => '_abtestkit_variant_in_use',
            'value'   => 1,
            'compare' => '!=',
            'type'    => 'NUMERIC',
        ],
    ];

    return $args;
}, 20, 2 );

/**
 * FRONTEND: Exclude Version B (shadow) variants from listings (blog archives, search, sliders, etc).
 * Does NOT affect singular views (direct access), so preview/edit flows still work.
 */
/**
 * FRONTEND: Exclude Version B variants from listings (blog archives, search, sliders, etc).
 * - Hides duplicates (shadow) via _abtestkit_shadow
 * - Hides existing Version B when SEO-safe is ON via _abtestkit_variant_in_use
 *
 * Applies to ALL frontend queries (not just main query) so Elementor widgets/sliders/custom WP_Query loops
 * also exclude Version B content.
 */
add_action( 'pre_get_posts', function( $q ) {
    if ( is_admin() ) return;
    if ( $q->is_singular() ) return;

    $pt = $q->get( 'post_type' );

    // Affect default blog queries, 'any', and any query that includes post/page (and optionally product).
    $affects = false;
    if ( empty( $pt ) || $pt === 'any' ) {
        $affects = true;
    } elseif ( is_string( $pt ) && in_array( $pt, [ 'post', 'page', 'product' ], true ) ) {
        $affects = true;
    } elseif ( is_array( $pt ) && array_intersect( $pt, [ 'post', 'page', 'product' ] ) ) {
        $affects = true;
    }

    if ( ! $affects ) return;

    $meta_query = (array) $q->get( 'meta_query' );

    // Remove any existing clauses we might add (defensive).
    $meta_query = array_values( array_filter( $meta_query, function( $clause ) {
        if ( ! is_array( $clause ) ) return true;
        if ( isset( $clause['key'] ) && in_array( $clause['key'], [ '_abtestkit_shadow', '_abtestkit_variant_in_use' ], true ) ) {
            return false;
        }
        return true;
    } ) );

    // Exclude duplicate B (shadow)
    $meta_query[] = [
        'key'     => '_abtestkit_shadow',
        'compare' => 'NOT EXISTS',
    ];

    // Exclude existing B when SEO-safe is ON
    $meta_query[] = [
        'key'     => '_abtestkit_variant_in_use',
        'compare' => 'NOT EXISTS',
    ];

    $q->set( 'meta_query', $meta_query );
}, 20 );

/**
 * If someone tries to publish a shadow variant (product/post/page), force it back to draft.
 * Also append a query flag so we can show a “blocked” notice after redirect.
 */
/**
 * If someone tries to publish a shadow PRODUCT, force it back to draft.
 * (We do NOT block publishing for shadow pages/posts.)
 */
add_filter( 'wp_insert_post_data', function( $data, $postarr ) {
    if ( empty( $postarr['ID'] ) ) return $data;

    $post_id = (int) $postarr['ID'];

    // Only apply to products (SKU/catalog safety).
    if ( empty( $data['post_type'] ) || $data['post_type'] !== 'product' ) {
        return $data;
    }

    if ( ! abtestkit_is_shadow_product( $post_id ) ) {
        return $data;
    }

    $attempting_publish = in_array( $data['post_status'], [ 'publish', 'future', 'private' ], true );

    if ( $attempting_publish ) {
        $data['post_status'] = 'draft';
        $GLOBALS['abtestkit_shadow_publish_blocked'] = 1;
    }

    return $data;
}, 99, 2 );

add_filter( 'redirect_post_location', function( $location, $post_id ) {
    if ( ! empty( $GLOBALS['abtestkit_shadow_publish_blocked'] ) && abtestkit_is_shadow_product( (int) $post_id ) ) {
        $location = add_query_arg( [ 'abtestkit_shadow_blocked' => 1 ], $location );
    }
    return $location;
}, 99, 2 );

/**
 * Fix the wp-admin "Mine" view count so it matches our list-table exclusion logic.
 * Core computes "Mine" separately, so the count can include Version B items even if hidden.
 */
function abtestkit_fix_mine_view_count( array $views, string $post_type ) : array {
    if ( empty( $views['mine'] ) ) {
        return $views;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
        return $views;
    }

    // Count ONLY items authored by current user that are NOT Version B and NOT trash/auto-draft.
    // (Matches the intent of core "Mine" without pulling in our hidden variants.)
    $cache_key  = 'mine_count_' . $post_type . '_' . (int) $user_id;
    $mine_count = wp_cache_get( $cache_key, 'abtestkit' );

    if ( false === $mine_count ) {
        $q = new WP_Query(
            array(
                'post_type'              => $post_type,
                'author'                 => (int) $user_id,

                // "Mine" should include anything except trash/auto-draft.
                // WP_Query doesn't support post_status NOT IN directly, so we whitelist the common valid statuses.
                'post_status'            => array( 'publish', 'draft', 'pending', 'future', 'private' ),

                'posts_per_page'         => 1,
                'fields'                 => 'ids',
                'no_found_rows'          => false, // we need found_posts
                'update_post_meta_cache' => false,
                'update_post_term_cache' => false,

                'meta_query'             => array(
                    'relation' => 'AND',
                    array(
                        'key'     => '_abtestkit_shadow',
                        'compare' => 'NOT EXISTS',
                    ),
                    array(
                        'key'     => '_abtestkit_variant_in_use',
                        'compare' => 'NOT EXISTS',
                    ),
                ),
            )
        );

        $mine_count = (int) $q->found_posts;

        // Cache briefly to satisfy WPCS without risking stale counts for long.
        wp_cache_set( $cache_key, $mine_count, 'abtestkit', 60 );
    }

    // Core only shows "Mine" if there's something there.
    if ( $mine_count <= 0 ) {
        unset( $views['mine'] );
        return $views;
    }

    $formatted = number_format_i18n( $mine_count );

    // Replace the "(n)" count inside the existing Mine link HTML.
    $views['mine'] = preg_replace(
        '/<span class="count">\\([^)]*\\)<\\/span>/',
        '<span class="count">(' . $formatted . ')</span>',
        (string) $views['mine']
    );

    return $views;
}

add_filter( 'views_edit-product', function( $views ) {

    if ( ! current_user_can( 'edit_products' ) && ! current_user_can( 'edit_posts' ) ) {
        return $views;
    }

    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen || $screen->id !== 'edit-product' ) {
        return $views;
    }

    $count_q = new WP_Query( [
        'post_type'      => 'product',
        'post_status'    => 'any',
        'posts_per_page' => 1,
        'fields'         => 'ids',
        'meta_query'     => [
            [
                'relation' => 'OR',
                [ 'key' => '_abtestkit_shadow',         'compare' => 'EXISTS' ],
                [ 'key' => '_abtestkit_variant_in_use', 'compare' => 'EXISTS' ],
            ],
        ],
    ] );
    $count = (int) $count_q->found_posts;

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $shadow_flag    = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
    $is_shadow_view = ( $shadow_flag === '1' );

    $url = add_query_arg(
        [ 'post_type' => 'product', 'abtestkit_shadow' => 1 ],
        admin_url( 'edit.php' )
    );

    $class = $is_shadow_view ? ' class="current"' : '';
    $label = sprintf(
        /* translators: %s: Number of Version B products. */
        __( 'Version B Products <span class="count">(%s)</span>', 'abtestkit' ),
        number_format_i18n( $count )
    );

    $shadow_link = sprintf( '<a href="%s"%s>%s</a>', esc_url( $url ), $class, $label );

    if ( isset( $views['all'] ) ) {
        $out = [ 'all' => $views['all'], 'abtestkit_shadow' => $shadow_link ];
        foreach ( $views as $k => $v ) {
            if ( $k === 'all' ) {
                continue;
            }
            $out[ $k ] = $v;
        }
        $views = $out;
    } else {
        $views['abtestkit_shadow'] = $shadow_link;
    }

    $views = abtestkit_fix_mine_view_count( $views, 'product' );
    return $views;
} );

/**
 * Add "Version B" view tabs in wp-admin list tables (Products / Posts / Pages).
 */

add_filter( 'views_edit-post', function( $views ) {

    if ( ! current_user_can( 'edit_posts' ) ) {
        return $views;
    }

    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen || $screen->id !== 'edit-post' ) {
        return $views;
    }

    $count_q = new WP_Query( [
        'post_type'      => 'post',
        'post_status'    => 'any',
        'posts_per_page' => 1,
        'fields'         => 'ids',
        'meta_query'     => [
            [ 'key' => '_abtestkit_shadow', 'compare' => 'EXISTS' ],
        ],
    ] );
    $count = (int) $count_q->found_posts;

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $shadow_flag    = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
    $is_shadow_view = ( $shadow_flag === '1' );

    $url = add_query_arg(
        [ 'post_type' => 'post', 'abtestkit_shadow' => 1 ],
        admin_url( 'edit.php' )
    );

    $class = $is_shadow_view ? ' class="current"' : '';
    $label = sprintf(
        /* translators: %s: Number of Version B posts. */
        __( 'Version B Posts <span class="count">(%s)</span>', 'abtestkit' ),
        number_format_i18n( $count )
    );

    $shadow_link = sprintf( '<a href="%s"%s>%s</a>', esc_url( $url ), $class, $label );

    if ( isset( $views['all'] ) ) {
        $out = [ 'all' => $views['all'], 'abtestkit_shadow' => $shadow_link ];
        foreach ( $views as $k => $v ) { if ( $k === 'all' ) continue; $out[ $k ] = $v; }
        $views = $out; // don't return yet — we also need to fix "Mine"
    } else {
        $views['abtestkit_shadow'] = $shadow_link;
    }

    $views = abtestkit_fix_mine_view_count( $views, 'post' );
    return $views;
} );

add_filter( 'views_edit-page', function( $views ) {

    if ( ! current_user_can( 'edit_pages' ) ) {
        return $views;
    }

    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen || $screen->id !== 'edit-page' ) {
        return $views;
    }

    $count_q = new WP_Query( [
        'post_type'      => 'page',
        'post_status'    => 'any',
        'posts_per_page' => 1,
        'fields'         => 'ids',
        'meta_query'     => [
            [ 'key' => '_abtestkit_shadow', 'compare' => 'EXISTS' ],
        ],
    ] );
    $count = (int) $count_q->found_posts;

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $shadow_flag    = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
    $is_shadow_view = ( $shadow_flag === '1' );

    $url = add_query_arg(
        [ 'post_type' => 'page', 'abtestkit_shadow' => 1 ],
        admin_url( 'edit.php' )
    );

    $class = $is_shadow_view ? ' class="current"' : '';
    $label = sprintf(
        /* translators: %s: Number of Version B pages. */
        __( 'Version B Pages <span class="count">(%s)</span>', 'abtestkit' ),
        number_format_i18n( $count )
    );

    $shadow_link = sprintf( '<a href="%s"%s>%s</a>', esc_url( $url ), $class, $label );

    if ( isset( $views['all'] ) ) {
        $out = [ 'all' => $views['all'], 'abtestkit_shadow' => $shadow_link ];
        foreach ( $views as $k => $v ) {
            if ( $k === 'all' ) continue;
            $out[ $k ] = $v;
        }
        $views = $out; // <-- IMPORTANT: do not return yet, we still need to fix "Mine"
    } else {
        $views['abtestkit_shadow'] = $shadow_link;
    }

    $views = abtestkit_fix_mine_view_count( $views, 'page' );
    return $views;
} );

/**
 * Hide Version B variants from wp-admin list tables by default,
 * but show them when the "Version B" view is active.
 *
 * Applies to Products, Posts, and Pages.
 *
 * - Duplicates use: _abtestkit_shadow
 * - Existing B (SEO-safe) uses: _abtestkit_variant_in_use
 *
 * This also ensures they never show up under the "Mine" view.
 */
add_action( 'pre_get_posts', function( $q ) {
    if ( ! is_admin() ) return;
    if ( ! $q->is_main_query() ) return;

    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen || ! in_array( $screen->id, [ 'edit-product', 'edit-post', 'edit-page' ], true ) ) return;

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $shadow_flag    = isset( $_GET['abtestkit_shadow'] ) ? sanitize_text_field( wp_unslash( $_GET['abtestkit_shadow'] ) ) : '';
    $is_shadow_view = ( $shadow_flag === '1' );

    // Trash view: keep variants visible so they can be deleted/recovered.
    $requested_status = $q->get( 'post_status' );
    if ( empty( $requested_status ) ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $requested_status = isset( $_GET['post_status'] ) ? sanitize_text_field( wp_unslash( $_GET['post_status'] ) ) : '';
    }
    $is_trash_view = (string) $requested_status === 'trash';

    $meta_query = (array) $q->get( 'meta_query' );

    // Remove any existing clauses we might add (defensive).
    $meta_query = array_values( array_filter( $meta_query, function( $clause ) {
        if ( ! is_array( $clause ) ) return true;
        if ( isset( $clause['key'] ) && in_array( $clause['key'], [ '_abtestkit_shadow', '_abtestkit_variant_in_use' ], true ) ) {
            return false;
        }
        return true;
    } ) );

    if ( $is_shadow_view ) {
        // Show ALL Version B items (duplicates + existing B in use).
        $meta_query[] = [
            'relation' => 'OR',
            [ 'key' => '_abtestkit_shadow',         'compare' => 'EXISTS' ],
            [ 'key' => '_abtestkit_variant_in_use', 'compare' => 'EXISTS' ],
        ];
        $q->set( 'meta_query', $meta_query );
        return;
    }

    if ( ! $is_trash_view ) {
        // Hide ALL Version B items from normal tabs (All, Published, Draft, Mine, etc).
        $meta_query[] = [ 'key' => '_abtestkit_shadow',         'compare' => 'NOT EXISTS' ];
        $meta_query[] = [ 'key' => '_abtestkit_variant_in_use', 'compare' => 'NOT EXISTS' ];
        $q->set( 'meta_query', $meta_query );
    }
}, 20 );

// Bust admin shadow-count cache when products/posts/pages change.
add_action( 'save_post', function( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }
    if ( empty( $post ) ) {
        return;
    }

    if ( in_array( $post->post_type, [ 'product', 'post', 'page' ], true ) ) {
        abtestkit_clear_shadow_counts_cache( $post->post_type );
    }
}, 10, 3 );

add_action( 'trashed_post', function( $post_id ) {
    $pt = get_post_type( $post_id );
    if ( in_array( $pt, [ 'product', 'post', 'page' ], true ) ) {
        abtestkit_clear_shadow_counts_cache( $pt );
    }
}, 10, 1 );

add_action( 'untrashed_post', function( $post_id ) {
    $pt = get_post_type( $post_id );
    if ( in_array( $pt, [ 'product', 'post', 'page' ], true ) ) {
        abtestkit_clear_shadow_counts_cache( $pt );
    }
}, 10, 1 );

add_action( 'deleted_post', function( $post_id ) {
    $pt = get_post_type( $post_id );
    if ( in_array( $pt, [ 'product', 'post', 'page' ], true ) ) {
        abtestkit_clear_shadow_counts_cache( $pt );
    }
}, 10, 1 );

// Optional: catches status transitions that don't go through save_post the way you expect.
add_action( 'transition_post_status', function( $new_status, $old_status, $post ) {
    if ( empty( $post ) ) return;
    if ( $new_status === $old_status ) return;

    if ( in_array( $post->post_type, [ 'product', 'post', 'page' ], true ) ) {
        abtestkit_clear_shadow_counts_cache( $post->post_type );
    }
}, 10, 3 );

add_filter( 'wp_count_posts', function( $counts, $type, $perm ) {

    if ( ! in_array( $type, [ 'product', 'post', 'page' ], true ) ) {
        return $counts;
    }

    if ( ! is_admin() ) {
        return $counts;
    }

    global $wpdb;

    $cache_group = 'abtestkit';
    $cache_key   = 'shadow_counts_by_status_' . $type . '_' . (int) get_current_blog_id();

    $rows = wp_cache_get( $cache_key, $cache_group );
    if ( false === $rows ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "
                SELECT p.post_status, COUNT(*) AS c
                FROM {$wpdb->posts} p
                INNER JOIN {$wpdb->postmeta} pm
                    ON pm.post_id = p.ID
                   AND pm.meta_key = %s
                WHERE p.post_type = %s
                GROUP BY p.post_status
                ",
                '_abtestkit_shadow',
                $type
            ),
            ARRAY_A
        );

        wp_cache_set( $cache_key, $rows, $cache_group, 60 );
    }

    if ( empty( $rows ) ) {
        return $counts;
    }

    foreach ( $rows as $r ) {
        $status = (string) $r['post_status'];
        $c      = (int) $r['c'];

        if ( $status === 'trash' ) {
            continue;
        }

        if ( isset( $counts->$status ) ) {
            $counts->$status = max( 0, (int) $counts->$status - $c );
        }
    }

    if ( isset( $counts->total ) ) {
        $sum = 0;
        foreach ( get_object_vars( $counts ) as $k => $v ) {
            if ( $k === 'total' ) continue;
            $sum += (int) $v;
        }
        $counts->total = $sum;
    }

    return $counts;
}, 10, 3 );

/**
 * Big banner on version b edit screens.
 */
add_action( 'admin_notices', function() {
    if ( ! is_admin() ) return;

    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen || $screen->base !== 'post' ) return;

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
    if ( ! $post_id ) return;

    $post = get_post( $post_id );
    if ( ! $post ) return;

    $post_type = (string) $post->post_type;

    // ───────────────────────────────────────────────────────────
    // 1) Existing banner: Shadow Product helper (keep behaviour)
    // ───────────────────────────────────────────────────────────
    if ( $post_type === 'product' && abtestkit_is_shadow_product( $post_id ) ) {

        $shadow_of = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );

        echo '<div class="notice notice-warning" style="border-left-color:#fc510b;">';
        echo '<p><strong>abtestkit:</strong> This is your <strong>Version B product</strong> for this A/B test.</p>';
        echo '<p>It is only used to build and preview the Version B experience. It will not go live as its own product.</p>';
        echo '<p>This product <strong>cannot be published</strong>. If you try to publish it, abtestkit will keep it saved as a draft.</p>';
        if ( $shadow_of ) {
            echo '<p>Linked Version A product ID: <code>' . esc_html( (string) $shadow_of ) . '</code></p>';
        }
        echo '<p><strong>When you are done, save as draft and close this tab to return to your test.</strong></p>';
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if ( ! empty( $_GET['abtestkit_shadow_blocked'] ) ) {
            echo '<p><strong style="color:#d63638;">Publish blocked:</strong> Shadow products are never allowed to go live.</p>';
        }
        echo '</div>';

        return;
    }

    // ───────────────────────────────────────────────────────────
    // 2) New banner: Version B helper for Posts + Pages
    //    - duplicates: _abtestkit_shadow (+ optional _abtestkit_shadow_of)
    //    - existing B:  _abtestkit_variant_in_use
    // ───────────────────────────────────────────────────────────
    if ( $post_type !== 'post' && $post_type !== 'page' ) {
        return;
    }

    $is_shadow    = (bool) get_post_meta( $post_id, '_abtestkit_shadow', true );
    $is_existingB = (bool) get_post_meta( $post_id, '_abtestkit_variant_in_use', true );

    if ( ! $is_shadow && ! $is_existingB ) {
        return;
    }

    $shadow_of = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );

    $type_label = ( $post_type === 'page' ) ? 'Page' : 'Post';

    echo '<div class="notice notice-warning" style="border-left-color:#fc510b;">';
    echo '<p><strong>abtestkit:</strong> You are editing a <strong>Version B ' . esc_html( $type_label ) . '</strong> (used for A/B testing).</p>';
    echo '<p>Make your changes, <strong>publish/update</strong>, then <strong>close this tab</strong> to continue creating your test.</p>';
    if ( $shadow_of ) {
        echo '<p>Linked Version A ' . esc_html( $type_label ) . ' ID: <code>' . esc_html( (string) $shadow_of ) . '</code></p>';
    }
    echo '</div>';
} );

// ── Admin Dashboard UI ───────────────────────────────────────────────────────
add_action( 'admin_menu', function () {
    // Top-level "abtestkit" menu (icon)
    add_menu_page(
        __( 'abtestkit', 'abtestkit' ),
        __( 'abtestkit', 'abtestkit' ),
        'manage_options',
        'abtestkit-dashboard',
        'abtestkit_render_page_tests_dashboard',
        plugins_url( 'assets/img/menu-icon.png', __FILE__ ),
        59
    );

    // Explicit "Dashboard" submenu under the icon
    add_submenu_page(
        'abtestkit-dashboard',
        __( 'Dashboard', 'abtestkit' ),
        __( 'Dashboard', 'abtestkit' ),
        'manage_options',
        'abtestkit-dashboard',
        'abtestkit_render_page_tests_dashboard'
    );
}, 9 );

/**
 * Show A/B test membership for pages, posts and products in the list tables.
 */
function abtestkit_register_ab_columns( $columns ) {
    $columns['abtestkit_variant'] = __( 'A/B Version', 'abtestkit' );
    $columns['abtestkit_test']    = __( 'A/B Test', 'abtestkit' );
    return $columns;
}

add_filter( 'manage_page_posts_columns', 'abtestkit_register_ab_columns' );
add_filter( 'manage_post_posts_columns', 'abtestkit_register_ab_columns' );

// WooCommerce products get a single "Test Status" column (no A/B Version column).
add_filter( 'manage_product_posts_columns', 'abtestkit_register_product_test_status_column' );

/**
 * Add a single "Test Status" column to WooCommerce products list table.
 * (Products don't have a physical Version B post, so A/B Version isn't shown here.)
 */
function abtestkit_register_product_test_status_column( $columns ) {
    // Insert right after the product name column when possible.
    $new = [];
    foreach ( $columns as $key => $label ) {
        $new[ $key ] = $label;
        if ( $key === 'name' ) {
            $new['abtestkit_test_status'] = __( 'Test Status', 'abtestkit' );
        }
    }
    if ( ! isset( $new['abtestkit_test_status'] ) ) {
        $new['abtestkit_test_status'] = __( 'Test Status', 'abtestkit' );
    }
    return $new;
}

/**
 * Render the "Test Status" column for WooCommerce products.
 */
function abtestkit_render_product_test_status_column( $column, $post_id ) {
    if ( $column !== 'abtestkit_test_status' ) {
        return;
    }

    // If this row is a shadow product, resolve its control product ID.
    $is_shadow = (bool) get_post_meta( $post_id, '_abtestkit_shadow', true );
    $shadow_of = (int) get_post_meta( $post_id, '_abtestkit_shadow_of', true );
    $control_id = ( $is_shadow && $shadow_of > 0 ) ? $shadow_of : (int) $post_id;

    $matches = [];

    foreach ( abtestkit_pt_all() as $t ) {
        if ( empty( $t['id'] ) ) {
            continue;
        }
        // Only product tests.
        if ( ( $t['kind'] ?? '' ) !== 'product' ) {
            continue;
        }
        // Product tests are tied to the control product (variant_id is 0).
        if ( (int) ( $t['control_id'] ?? 0 ) !== (int) $control_id ) {
            continue;
        }
        $matches[] = $t;
    }

    if ( empty( $matches ) ) {
        echo '—';
        return;
    }

    // Prefer running > paused > draft > complete (best-effort).
    $rank = [ 'running' => 1, 'paused' => 2, 'draft' => 3, 'complete' => 4 ];
    usort( $matches, function( $a, $b ) use ( $rank ) {
        $sa = (string) ( $a['status'] ?? 'paused' );
        $sb = (string) ( $b['status'] ?? 'paused' );
        $ra = $rank[ $sa ] ?? 99;
        $rb = $rank[ $sb ] ?? 99;
        return $ra <=> $rb;
    } );

    $t       = $matches[0];
    $status  = (string) ( $t['status'] ?? 'paused' );
    $title   = (string) ( $t['title'] ?? $t['id'] ?? '' );
    $test_id = (string) ( $t['id'] ?? '' );

    switch ( $status ) {
        case 'running':
            $status_label = __( 'Running', 'abtestkit' );
            break;
        case 'complete':
            $status_label = __( 'Complete', 'abtestkit' );
            break;
        case 'draft':
            $status_label = __( 'Draft', 'abtestkit' );
            break;
        case 'paused':
        default:
            $status_label = __( 'Paused', 'abtestkit' );
            break;
    }

    $url = add_query_arg(
        [
            'page'      => 'abtestkit-dashboard',
            'tab'       => 'products',
            'highlight' => $test_id,
        ],
        admin_url( 'admin.php' )
    );

    printf(
        '<a href="%s" title="%s">%s</a>',
        esc_url( $url ),
        esc_attr( $title ),
        esc_html( $status_label )
    );
}

/**
 * Render A/B test info in admin list columns.
 */
function abtestkit_render_ab_columns( $column, $post_id ) {

    if ( $column !== 'abtestkit_variant' && $column !== 'abtestkit_test' ) {
        return;
    }

    $variant_label = '—';
    $test_title    = '—';
    $test_status   = '';
    $test_id       = '';
    $post_type     = get_post_type( $post_id );

    foreach ( abtestkit_pt_all() as $t ) {
        if ( empty( $t['id'] ) ) {
            continue;
        }

        $control_id = isset( $t['control_id'] ) ? (int) $t['control_id'] : 0;
        $variant_id = isset( $t['variant_id'] ) ? (int) $t['variant_id'] : 0;

        if ( $control_id === (int) $post_id ) {
            $variant_label = __( 'Version A', 'abtestkit' );
            $test_title    = $t['title'] ?? $t['id'];
            $test_status   = $t['status'] ?? 'paused';
            $test_id       = (string) ( $t['id'] ?? '' );
            break;
        }

        if ( $variant_id === (int) $post_id ) {
            $variant_label = __( 'Version B', 'abtestkit' );
            $test_title    = $t['title'] ?? $t['id'];
            $test_status   = $t['status'] ?? 'paused';
            $test_id       = (string) ( $t['id'] ?? '' );
            break;
        }
    }

    if ( $column === 'abtestkit_variant' ) {
        echo esc_html( $variant_label );
        return;
    }

    // A/B Test column
    if ( $test_title === '—' ) {
        echo '—';
        return;
    }

    // Human label for status (stored values: paused|running|complete|draft)
    switch ( (string) $test_status ) {
        case 'running':
            $status_label = __( 'Running', 'abtestkit' );
            break;
        case 'complete':
            $status_label = __( 'Complete', 'abtestkit' );
            break;
        case 'draft':
            $status_label = __( 'Draft', 'abtestkit' );
            break;
        case 'paused':
        default:
            $status_label = __( 'Paused', 'abtestkit' );
            break;
    }

    $linked_text = sprintf(
        '%s (%s)',
        (string) $test_title,
        (string) $status_label
    );

    // “Relevant tab” (best-effort): pages/posts/products.
    // Even if your React dashboard ignores this, it won’t break anything.
    $tab = 'pages';
    if ( $post_type === 'post' ) {
        $tab = 'posts';
    } elseif ( $post_type === 'product' ) {
        $tab = 'products';
    }

    $url = add_query_arg(
        [
            'page'      => 'abtestkit-dashboard',
            'tab'       => $tab,
            'highlight' => $test_id,
        ],
        admin_url( 'admin.php' )
    );

    printf(
        '<a href="%s">%s</a>',
        esc_url( $url ),
        esc_html( $linked_text )
    );
}

add_action( 'manage_page_posts_custom_column', 'abtestkit_render_ab_columns', 10, 2 );
add_action( 'manage_post_posts_custom_column', 'abtestkit_render_ab_columns', 10, 2 );
add_action( 'manage_product_posts_custom_column', 'abtestkit_render_product_test_status_column', 10, 2 );

/**
 * Small CSS tweak so the columns don’t squash.
 */
add_action( 'admin_head-edit.php', function () {
    ?>
    <style>
        .column-abtestkit_variant,
        .column-abtestkit_test,
        .column-abtestkit_test_status {
            width: 120px;
        }
    </style>
    <?php
} );

/** Handle create/start/pause/reset/apply/delete */
add_action( 'admin_post_abtestkit_pt_action', function () {
    if ( ! current_user_can( 'manage_options' ) ) wp_die( 'forbidden' );
    check_admin_referer( 'abtestkit_pt_action' );

    $do = sanitize_key( $_POST['do'] ?? '' );
    $id         = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
    $return_url = isset( $_POST['return_url'] ) ? esc_url_raw( wp_unslash( $_POST['return_url'] ) ) : '';

    if ( $do === 'create' ) {
        $src = absint( $_POST['source_page'] ?? 0 );
        if ( $src ) {
            $post_type = get_post_type( $src );

            if ( $post_type === 'product' ) {
                // Product test: no duplicate product; we'll use overrides on the control product.
                $b_id = 0;
            } else {
                $b_id = abtestkit_duplicate_post_deep( $src );
            }

            if ( $post_type === 'product' || $b_id ) {
                $test = [
                    'id'              => 'pt-' . substr( md5( $src . '|' . microtime( true ) ), 0, 8 ),
                    'title'           => get_the_title( $src ),
                    'control_id'      => $src,
                    'variant_id'      => ( $post_type === 'product' ) ? 0 : $b_id,
                    'status'          => 'paused',
                    'split'           => 50,
                    'cookie_ttl_days' => 30,
                    'started_at'      => 0,
                    'finished_at'     => 0,
                    'paused_at'       => 0,
                    'paused_total'    => 0,
                    'kind'            => ( $post_type === 'product' ) ? 'product' : 'page',
                ];
                abtestkit_pt_put( $test );

                // Once the user has created a test, never show onboarding again.
                update_option( 'abtestkit_onboarding_done', '1' );

                wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'made' => '1' ], admin_url( 'admin.php' ) ) );
                exit;
            }
        }

        wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'create_failed' ], admin_url( 'admin.php' ) ) );
        exit;
    }

    if ( ! $id ) {
        wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'no_id' ], admin_url( 'admin.php' ) ) );
        exit;
    }
    $test = abtestkit_pt_get( $id );
    if ( ! $test ) {
        wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'error' => 'not_found' ], admin_url( 'admin.php' ) ) );
        exit;
    }

    switch ( $do ) {
        case 'start':
            // Block starting if either A or B page is already used by another running test
            $conflicts = abtestkit_pt_conflicts_for_pages( (int) $test['control_id'], (int) $test['variant_id'], (string) $test['id'] );
            if ( ! empty( $conflicts ) ) {
                // Bounce back with an error flag; UI can show a notice
                wp_safe_redirect( add_query_arg(
                    [ 'page' => 'abtestkit-dashboard', 'error' => 'conflict_running', 'conflicts' => implode(',', $conflicts) ],
                    admin_url( 'admin.php' )
                ) );
                exit;
            }

            // Preserve the original started_at across pause/resume.
            // We track paused_total for runtime calculations, but we must NOT move started_at,
            // otherwise the performance timeline loses earlier history after resume.
            $prev_status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';

            if ( $prev_status === 'paused' ) {
                $paused_at = isset( $test['paused_at'] ) ? (int) $test['paused_at'] : 0;
                if ( $paused_at > 0 ) {
                    $delta = max( 0, time() - $paused_at );
                    $test['paused_total'] = (int) ( $test['paused_total'] ?? 0 ) + $delta;
                    $test['paused_at']    = 0;
                }
            }

            if ( (int) ( $test['started_at'] ?? 0 ) <= 0 || $prev_status === 'draft' ) {
                $test['started_at'] = time();
            }

            $test['status'] = 'running';
            abtestkit_pt_put( $test );

            if ( $return_url ) {
                wp_safe_redirect( $return_url );
                exit;
            }
            break;

        case 'pause':
            // Record when the pause started so we can exclude it from runtime on resume.
            if ( ( $test['status'] ?? 'paused' ) === 'running' ) {
                $test['paused_at'] = time();
                if ( ! isset( $test['paused_total'] ) ) {
                    $test['paused_total'] = 0;
                }
            }

            $test['status'] = 'paused';
            abtestkit_pt_put( $test );

            if ( $return_url ) {
                wp_safe_redirect( $return_url );
                exit;
            }
            break;
        case 'delete':
            $variant_id = (int) ( $test['variant_id'] ?? 0 );
            $control_id = (int) ( $test['control_id'] ?? 0 );

            // Only auto-trash Version B if it is one of our shadow products (safe cleanup).
            $is_shadow_b = false;
            if ( $variant_id > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $variant_id ) ) {
                $shadow_of   = (int) get_post_meta( $variant_id, '_abtestkit_shadow_of', true );
                $is_shadow_b = ( $control_id > 0 && $shadow_of === $control_id );
            }

            // If the UI posts trash_b, respect it.
            // If not posted (e.g. user hit "Cancel" on the confirm), default to NO.
            // This prevents accidental deletion of Version B.
            if ( isset( $_POST['trash_b'] ) ) {
                $trash_b = ( sanitize_text_field( wp_unslash( $_POST['trash_b'] ) ) === '1' );
            } else {
                $trash_b = false;
            }

            if ( $trash_b && $variant_id > 0 && get_post_status( $variant_id ) ) {
                // Safety: never trash a non-shadow variant automatically.
                if ( $is_shadow_b ) {
                    wp_trash_post( $variant_id );
                }
            }

            abtestkit_pt_delete( (string) $test['id'] );
            break;

        case 'reset':
            global $wpdb;
            $table = defined( 'ABTESTKIT_EVENTS_TABLE' ) ? ABTESTKIT_EVENTS_TABLE : ( $wpdb->prefix . 'abtestkit_events' );
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query(
                $wpdb->prepare(
                    "DELETE FROM {$table}
                     WHERE ab_test_id = %s",
                    (string) $test['id']
                )
            );
            // phpcs:enable
            break;
        case 'apply_b_winner':
            // Two flavours:
            // - 'page' tests: copy the B page's content/meta/taxonomies onto A, then mark test complete.
            // - 'product' tests: commit overrides from the virtual B onto the real product, then mark test complete.
            $kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';

            // Snapshot now (before we overwrite A / commit overrides / trash B).
            abtestkit_pt_snapshot_completed_test( $test );

            if ( $kind === 'product' ) {
                global $wpdb;

                $product_id = (int) $test['control_id'];
                $shadow_id  = (int) $test['variant_id'];

                if (
                    $product_id > 0
                    && $shadow_id > 0
                    && function_exists( 'wc_get_product' )
                    && get_post_type( $product_id ) === 'product'
                    && get_post_type( $shadow_id ) === 'product'
                    && function_exists( 'abtestkit_is_shadow_product' )
                    && abtestkit_is_shadow_product( $shadow_id )
                    && (int) get_post_meta( $shadow_id, '_abtestkit_shadow_of', true ) === $product_id
                ) {
                    $shadow_post    = get_post( $shadow_id );
                    $control_post   = get_post( $product_id );
                    $shadow_product = wc_get_product( $shadow_id );

                    if ( $shadow_post && $control_post && ( $shadow_product instanceof WC_Product ) ) {

                        /*
                         * 1) Copy core post fields from B -> A
                         * Keep A's ID, slug, status and authoring identity stable.
                         */
                        wp_update_post(
                            [
                                'ID'           => $product_id,
                                'post_title'   => (string) $shadow_post->post_title,
                                'post_content' => (string) $shadow_post->post_content,
                                'post_excerpt' => (string) $shadow_post->post_excerpt,
                                'menu_order'   => (int) $shadow_post->menu_order,
                            ]
                        );

                        /*
                         * 2) Copy taxonomies from B -> A
                         * This brings across product categories, tags, attributes, etc.
                         */
                        $taxonomies = get_object_taxonomies( 'product' );
                        foreach ( $taxonomies as $taxonomy ) {
                            $terms = wp_get_object_terms( $shadow_id, $taxonomy, [ 'fields' => 'ids' ] );
                            if ( ! is_wp_error( $terms ) ) {
                                wp_set_object_terms( $product_id, $terms, $taxonomy, false );
                            }
                        }

                        /*
                         * 3) Replace most post meta on A with B's post meta
                         * This is the important part for ACF/template/meta-driven setups.
                         *
                         * We preserve A-side identity/commerce history fields so the
                         * live product keeps its real stock/order/review/SKU record.
                         */
                        $preserve_meta_keys = [
                            '_sku',
                            '_stock',
                            '_stock_status',
                            '_manage_stock',
                            '_backorders',
                            '_backorders_allowed',
                            '_backordered',
                            'total_sales',
                            '_wc_average_rating',
                            '_wc_review_count',
                            '_wc_rating_count',
                            '_edit_lock',
                            '_edit_last',

                            /*
                             * Keep A's SEO/plugin identity meta intact.
                             * This preserves the live product's existing SEO setup
                             * even when Version B content/design/meta is applied.
                             */
                            '_yoast_wpseo_title',
                            '_yoast_wpseo_metadesc',
                            '_yoast_wpseo_focuskw',
                            '_yoast_wpseo_focuskeywords',
                            '_yoast_wpseo_canonical',
                            '_yoast_wpseo_opengraph-title',
                            '_yoast_wpseo_opengraph-description',
                            '_yoast_wpseo_opengraph-image',
                            '_yoast_wpseo_twitter-title',
                            '_yoast_wpseo_twitter-description',
                            '_yoast_wpseo_twitter-image',
                            '_yoast_wpseo_meta-robots-noindex',
                            '_yoast_wpseo_meta-robots-nofollow',
                            '_yoast_wpseo_schema_page_type',
                            '_yoast_wpseo_schema_article_type',

                            'rank_math_title',
                            'rank_math_description',
                            'rank_math_focus_keyword',
                            'rank_math_canonical_url',
                            'rank_math_robots',
                            'rank_math_facebook_title',
                            'rank_math_facebook_description',
                            'rank_math_facebook_image',
                            'rank_math_twitter_title',
                            'rank_math_twitter_description',
                            'rank_math_twitter_image',
                            'rank_math_schema_type',

                            '_aioseo_title',
                            '_aioseo_description',
                            '_aioseo_keywords',
                            '_aioseo_og_title',
                            '_aioseo_og_description',
                            '_aioseo_og_image',
                            '_aioseo_twitter_title',
                            '_aioseo_twitter_description',
                            '_aioseo_twitter_image',
                            '_aioseo_canonical_url',
                            '_aioseo_robots_noindex',
                            '_aioseo_robots_nofollow',

                            // Never copy shadow markers onto the real product.
                            '_abtestkit_shadow',
                            '_abtestkit_shadow_of',
                            '_abtestkit_variant_in_use',
                            '_abtestkit_variant_of',
                            '_abtestkit_variant_test_id',
                        ];

                        $shadow_meta_cache_key   = 'abtestkit_shadow_meta_rows_' . (int) $shadow_id;
                        $product_meta_cache_key  = 'abtestkit_product_meta_keys_' . (int) $product_id;

                        $meta_rows = wp_cache_get( $shadow_meta_cache_key, 'abtestkit' );
                        if ( false === $meta_rows ) {
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
                            $meta_rows = $wpdb->get_results(
                                $wpdb->prepare(
                                    "SELECT meta_key, meta_value
                                     FROM {$wpdb->postmeta}
                                     WHERE post_id = %d",
                                    $shadow_id
                                ),
                                ARRAY_A
                            );

                            wp_cache_set( $shadow_meta_cache_key, $meta_rows, 'abtestkit', MINUTE_IN_SECONDS );
                        }

                        if ( is_array( $meta_rows ) ) {
                            // Clear existing A meta except preserved keys.
                            $existing_keys = wp_cache_get( $product_meta_cache_key, 'abtestkit' );
                            if ( false === $existing_keys ) {
                                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
                                $existing_keys = $wpdb->get_col(
                                    $wpdb->prepare(
                                        "SELECT meta_key
                                         FROM {$wpdb->postmeta}
                                         WHERE post_id = %d",
                                        $product_id
                                    )
                                );

                                wp_cache_set( $product_meta_cache_key, $existing_keys, 'abtestkit', MINUTE_IN_SECONDS );
                            }

                            if ( is_array( $existing_keys ) ) {
                                foreach ( $existing_keys as $meta_key ) {
                                    $meta_key = (string) $meta_key;
                                    if ( in_array( $meta_key, $preserve_meta_keys, true ) ) {
                                        continue;
                                    }
                                    delete_post_meta( $product_id, $meta_key );
                                }
                            }

                            // Copy B meta onto A, excluding preserved keys.
                            foreach ( $meta_rows as $row ) {
                                $meta_key = isset( $row['meta_key'] ) ? (string) $row['meta_key'] : '';
                                if ( $meta_key === '' ) {
                                    continue;
                                }

                                if ( in_array( $meta_key, $preserve_meta_keys, true ) ) {
                                    continue;
                                }

                                $meta_value = $row['meta_value'] ?? '';
                                add_post_meta( $product_id, $meta_key, maybe_unserialize( $meta_value ) );
                            }
                        }

                        /*
                         * 4) Make absolutely sure key Woo price/image fields are aligned
                         * with the shadow product after meta copy.
                         */
                        $regular_price = $shadow_product->get_regular_price( 'edit' );
                        $sale_price    = $shadow_product->get_sale_price( 'edit' );
                        $active_price  = $shadow_product->get_price( 'edit' );
                        $image_id      = (int) $shadow_product->get_image_id( 'edit' );
                        $gallery_ids   = $shadow_product->get_gallery_image_ids( 'edit' );

                        if ( $regular_price !== '' && $regular_price !== null ) {
                            update_post_meta( $product_id, '_regular_price', wc_format_decimal( $regular_price ) );
                        }

                        if ( $sale_price !== '' && $sale_price !== null ) {
                            update_post_meta( $product_id, '_sale_price', wc_format_decimal( $sale_price ) );
                        } else {
                            delete_post_meta( $product_id, '_sale_price' );
                        }

                        if ( $active_price !== '' && $active_price !== null ) {
                            update_post_meta( $product_id, '_price', wc_format_decimal( $active_price ) );
                        }

                        if ( $image_id > 0 ) {
                            update_post_meta( $product_id, '_thumbnail_id', $image_id );
                        } else {
                            delete_post_meta( $product_id, '_thumbnail_id' );
                        }

                        if ( is_array( $gallery_ids ) ) {
                            $gallery_ids = array_values( array_filter( array_map( 'absint', $gallery_ids ) ) );
                            update_post_meta( $product_id, '_product_image_gallery', implode( ',', $gallery_ids ) );
                        }

                        /*
                         * 5) Clear caches/transients
                         */
                        clean_post_cache( $product_id );
                        clean_object_term_cache( $product_id, 'product' );
                        wp_cache_delete( $product_id, 'posts' );

                        if ( function_exists( 'wc_delete_product_transients' ) ) {
                            wc_delete_product_transients( $product_id );
                            wc_delete_product_transients( $shadow_id );
                        }

                        /*
                         * 6) Auto-trash the shadow B product now it has been applied
                         */
                        if ( get_post_status( $shadow_id ) ) {
                            wp_trash_post( $shadow_id );
                        }
                    }
                }

                /*
                 * 7) Remove the test entirely so frontend assignment stops cleanly.
                 * No need to keep a live completed product test once B is committed.
                 */
                $test['status']      = 'complete';
                $test['winner']      = 'B';
                $test['finished_at'] = time();
                abtestkit_pt_put( $test );
                break;
            }

            // Default behaviour for page tests (with a real B page)
            $A = get_post( $test['control_id'] );
            $B = get_post( $test['variant_id'] );
            if ( $A && $B ) {
                // 1) Content/excerpt
                wp_update_post( [
                    'ID'           => $A->ID,
                    'post_content' => $B->post_content,
                    'post_excerpt' => $B->post_excerpt,
                ] );
                // 2) Meta
                $meta = get_post_meta( $B->ID );
                foreach ( $meta as $k => $vals ) {
                    if ( in_array( $k, [ '_edit_lock','_edit_last','_wp_old_slug' ], true ) ) {
                        continue;
                    }
                    delete_post_meta( $A->ID, $k );
                    foreach ( (array) $vals as $v ) {
                        add_post_meta( $A->ID, $k, maybe_unserialize( $v ) );
                    }
                }
                // 3) Taxonomies
                $taxes = get_object_taxonomies( $A->post_type );
                foreach ( $taxes as $tx ) {
                    $terms = wp_get_object_terms( $B->ID, $tx, [ 'fields' => 'ids' ] );
                    if ( ! is_wp_error( $terms ) ) {
                        wp_set_object_terms( $A->ID, $terms, $tx, false );
                    }
                }

                $test['status']      = 'complete';
                $test['finished_at'] = time();
                abtestkit_pt_put( $test );
                // If Version B is an existing page/post (not a shadow duplicate), restore SEO now the test is finished.
                if ( $B && ! abtestkit_is_shadow_variant( (int) $B->ID ) ) {
                    abtestkit_pt_unmark_existing_variant_in_use( (int) $B->ID, (string) $test['id'] );
                }

                wp_trash_post( $B->ID ); // optional
            }
            break;
        case 'keep_a_winner':
            $kind       = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
            $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
            $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;

            // Snapshot now before removing the live test record.
            abtestkit_pt_snapshot_completed_test( $test );

            // PRODUCT TESTS:
            // Keep A live exactly as-is, auto-trash the shadow B product if it is a valid shadow of A,
            // but keep the test record and mark it complete so it still shows on the dashboard.
            if ( $kind === 'product' ) {
                $trashed_b   = false;
                $is_shadow_b = false;

                if (
                    $variant_id > 0
                    && $control_id > 0
                    && function_exists( 'abtestkit_is_shadow_product' )
                    && abtestkit_is_shadow_product( $variant_id )
                ) {
                    $shadow_of   = (int) get_post_meta( $variant_id, '_abtestkit_shadow_of', true );
                    $is_shadow_b = ( $shadow_of === $control_id );
                }

                if ( $is_shadow_b && get_post_status( $variant_id ) ) {
                    wp_trash_post( $variant_id );
                    $trashed_b = true;
                }

                $test['status']      = 'complete';
                $test['winner']      = 'A';
                $test['finished_at'] = time();
                abtestkit_pt_put( $test );

                if ( function_exists( 'abtestkit_send_telemetry' ) && abtestkit_is_telemetry_opted_in() ) {
                    abtestkit_send_telemetry( 'winner_applied', [
                        'type'       => 'keep_a',
                        'test_id'    => (string) $test['id'],
                        'control_id' => $control_id,
                        'variant_id' => $variant_id,
                        'trashed_b'  => $trashed_b ? 1 : 0,
                    ] );
                }

                break;
            }

            // PAGE / POST TESTS:
            // End test, keep A as-is, optionally trash B depending on user choice.
            $test['status']      = 'complete';
            $test['finished_at'] = time();
            abtestkit_pt_put( $test );

            $trash_b_raw = isset( $_POST['trash_b'] )
                ? sanitize_text_field( wp_unslash( $_POST['trash_b'] ) )
                : '0';

            $trash_b = ( '1' === $trash_b_raw );

            if ( $trash_b && $variant_id > 0 && get_post_status( $variant_id ) ) {

                // If Version B is an existing page/post (not a shadow duplicate), restore SEO now the test is finished.
                if ( ! abtestkit_is_shadow_variant( $variant_id ) ) {
                    abtestkit_pt_unmark_existing_variant_in_use( $variant_id, (string) $test['id'] );
                }

                wp_trash_post( $variant_id );
            }

            if ( function_exists( 'abtestkit_send_telemetry' ) && abtestkit_is_telemetry_opted_in() ) {
                abtestkit_send_telemetry( 'winner_applied', [
                    'type'       => 'keep_a',
                    'test_id'    => (string) $test['id'],
                    'control_id' => $control_id,
                    'variant_id' => $variant_id,
                    'trashed_b'  => $trash_b ? 1 : 0,
                ] );
            }
            break;
        case 'save_split':
            $split = max( 0, min( 100, intval( $_POST['split'] ?? 50 ) ) );
            $test['split'] = $split;
            abtestkit_pt_put( $test );
            break;
    }

    wp_safe_redirect( add_query_arg( [ 'page' => 'abtestkit-dashboard', 'updated' => '1' ], admin_url( 'admin.php' ) ) );
    exit;
} );

/** Stats helper (reads abtestkit_events) */
/**
 * Compute stats for snapshotting (by ab_test_id only).
 * This avoids "0 stats" issues when events don't have a reliable post_id (e.g. Woo add_to_cart / AJAX).
 */
function abtestkit_pt_stats_for_snapshot( string $test_id ) : array {
    global $wpdb;

    $out = [
        'A' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
        'B' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
    ];

    if ( $test_id === '' ) {
        return $out;
    }

    $table = ABTESTKIT_EVENTS_TABLE;

    if ( abtestkit_events_table_has_column( 'amount' ) ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
                'FROM %i ' .
                'WHERE ab_test_id = %s ' .
                "AND event_type IN ('impression','click','purchase') " .
                'GROUP BY variant, event_type',
                $table,
                $test_id
            ),
            ARRAY_A
        );
    } else {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT variant, event_type, COUNT(*) AS c, 0 AS revenue ' .
                'FROM %i ' .
                'WHERE ab_test_id = %s ' .
                "AND event_type IN ('impression','click','purchase') " .
                'GROUP BY variant, event_type',
                $table,
                $test_id
            ),
            ARRAY_A
        );
    }

    foreach ( (array) $rows as $r ) {
        $v       = isset( $r['variant'] ) ? $r['variant'] : '';
        $t       = isset( $r['event_type'] ) ? $r['event_type'] : '';
        $c       = isset( $r['c'] ) ? (int) $r['c'] : 0;
        $revenue = isset( $r['revenue'] ) ? (float) $r['revenue'] : 0.0;

        if ( ! isset( $out[ $v ] ) ) {
            continue;
        }

        if ( $t === 'purchase' ) {
            $out[ $v ]['purchases'] += $c;
            $out[ $v ]['revenue']   += $revenue;
        } elseif ( isset( $out[ $v ][ $t . 's' ] ) ) {
            $out[ $v ][ $t . 's' ] += $c;
        }
    }

    foreach ( [ 'A', 'B' ] as $variant ) {
        $purchases = (int) $out[ $variant ]['purchases'];
        $revenue   = (float) $out[ $variant ]['revenue'];
        $out[ $variant ]['revenue_per_customer'] = $purchases > 0 ? round( $revenue / $purchases, 2 ) : 0.0;
        $out[ $variant ]['revenue'] = round( $revenue, 2 );
    }

    return $out;
}

function abtestkit_pt_capture_post_snapshot( int $post_id ) : array {
    $p = $post_id > 0 ? get_post( $post_id ) : null;
    if ( ! ( $p instanceof WP_Post ) ) {
        return [];
    }

    return [
        'id'            => (int) $p->ID,
        'post_type'     => (string) $p->post_type,
        'post_status'   => (string) $p->post_status,
        'post_title'    => (string) $p->post_title,
        'post_name'     => (string) $p->post_name,
        'post_date_gmt' => (string) $p->post_date_gmt,
        'modified_gmt'  => (string) $p->post_modified_gmt,
        'post_excerpt'  => (string) $p->post_excerpt,
        'post_content'  => (string) $p->post_content,
    ];
}

function abtestkit_pt_capture_product_snapshot( int $product_id ) : array {
    $p = $product_id > 0 ? get_post( $product_id ) : null;
    if ( ! ( $p instanceof WP_Post ) ) {
        return [];
    }

    $regular = get_post_meta( $product_id, '_regular_price', true );
    $sale    = get_post_meta( $product_id, '_sale_price', true );
    $price   = get_post_meta( $product_id, '_price', true );

    if ( function_exists( 'wc_get_product' ) ) {
        $prod = wc_get_product( $product_id );
        if ( $prod ) {
            $regular = $prod->get_regular_price();
            $sale    = $prod->get_sale_price();
            $price   = $prod->get_price();
        }
    }

    $image_id    = (int) get_post_thumbnail_id( $product_id );
    $gallery_csv = (string) get_post_meta( $product_id, '_product_image_gallery', true );
    $gallery_ids = [];
    if ( $gallery_csv !== '' ) {
        $gallery_ids = array_values(
            array_filter(
                array_map(
                    'intval',
                    array_map( 'trim', explode( ',', $gallery_csv ) )
                )
            )
        );
    }

    return [
        'id'                => (int) $p->ID,
        'post_title'        => (string) $p->post_title,
        'short_description' => (string) $p->post_excerpt,
        'description'       => (string) $p->post_content,
        'regular_price'     => (string) $regular,
        'sale_price'        => (string) $sale,
        'price'             => (string) $price,
        'image_id'          => (int) $image_id,
        'gallery_ids'       => $gallery_ids,
    ];
}

/**
 * Snapshot the "final" state of a test so Completed/Winner tabs remain stable forever.
 * - final_stats: A/B impressions+clicks computed by ab_test_id
 * - snapshot_versions: the exact A and B versions at snapshot time
 */
function abtestkit_pt_snapshot_completed_test( array &$test ) : void {
    $test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
    if ( $test_id === '' ) {
        return;
    }

    if ( empty( $test['final_stats'] ) || ! is_array( $test['final_stats'] ) ) {
        $test['final_stats'] = abtestkit_pt_stats_for_snapshot( $test_id );
    }

    if ( empty( $test['snapshot_at'] ) ) {
        $test['snapshot_at'] = time();
    }

    if ( empty( $test['snapshot_versions'] ) || ! is_array( $test['snapshot_versions'] ) ) {
        $kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';

        if ( $kind === 'product' ) {
            $a = abtestkit_pt_capture_product_snapshot( (int) ( $test['control_id'] ?? 0 ) );

            // For product tests, B might be "virtual" via overrides (variant_id can be 0).
            $b = [];
            $variant_id = (int) ( $test['variant_id'] ?? 0 );
            if ( $variant_id > 0 ) {
                $b = abtestkit_pt_capture_product_snapshot( $variant_id );
            } else {
                $b = $a;
                $overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
                foreach ( $overrides as $k => $v ) {
                    $b[ $k ] = $v;
                }
                $b['virtual'] = true;
            }

            $test['snapshot_versions'] = [
                'A' => $a,
                'B' => $b,
            ];
        } else {
            $test['snapshot_versions'] = [
                'A' => abtestkit_pt_capture_post_snapshot( (int) ( $test['control_id'] ?? 0 ) ),
                'B' => abtestkit_pt_capture_post_snapshot( (int) ( $test['variant_id'] ?? 0 ) ),
            ];
        }
    }
}

function abtestkit_pt_stats_default() : array {
    return [
        'A' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
        'B' => [ 'impressions' => 0, 'clicks' => 0, 'purchases' => 0, 'revenue' => 0.0, 'revenue_per_customer' => 0.0 ],
    ];
}

function abtestkit_pt_stats_cache_key( string $test_id ) : string {
    return 'abtestkit_pt_stats_' . md5( $test_id );
}

function abtestkit_pt_performance_cache_key( string $test_id, string $range ) : string {
    return 'abtestkit_pt_perf_' . md5( $test_id . '|' . $range );
}

function abtestkit_pt_dashboard_cache_key( array $tests ) : string {
    $fingerprint = [];

    foreach ( $tests as $test ) {
        $fingerprint[] = [
            'id'         => (string) ( $test['id'] ?? '' ),
            'status'     => (string) ( $test['status'] ?? '' ),
            'updated_at' => (int) ( $test['snapshot_at'] ?? 0 ),
            'winner'     => (string) ( $test['winner'] ?? '' ),
            'title'      => (string) ( $test['title'] ?? '' ),
        ];
    }

    return 'abtestkit_pt_dash_' . md5( wp_json_encode( $fingerprint ) );
}

function abtestkit_pt_performance_cache_ttl( string $range ) : int {
    switch ( $range ) {
        case 'month':
            return 10 * MINUTE_IN_SECONDS;
        case 'week':
            return 5 * MINUTE_IN_SECONDS;
        case 'day':
        default:
            return 2 * MINUTE_IN_SECONDS;
    }
}

function abtestkit_pt_timeline_days_back( string $range ) : int {
    switch ( $range ) {
        case 'month':
            return 365;
        case 'week':
            return 182;
        case 'day':
        default:
            return 45;
    }
}

function abtestkit_pt_flush_test_caches( string $test_id ) : void {
    $test_id = (string) $test_id;
    if ( $test_id === '' ) {
        return;
    }

    delete_transient( abtestkit_pt_stats_cache_key( $test_id ) );
    delete_transient( abtestkit_pt_performance_cache_key( $test_id, 'day' ) );
    delete_transient( abtestkit_pt_performance_cache_key( $test_id, 'week' ) );
    delete_transient( abtestkit_pt_performance_cache_key( $test_id, 'month' ) );
}

function abtestkit_pt_stats_bulk( array $tests ) : array {
    global $wpdb;

    $out = [];

    if ( empty( $tests ) || ! is_array( $tests ) ) {
        return $out;
    }

    $live_test_ids = [];

    foreach ( $tests as $test ) {
        $test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
        $status  = isset( $test['status'] ) ? (string) $test['status'] : 'paused';

        if ( $test_id === '' ) {
            continue;
        }

        if (
            in_array( $status, [ 'winner', 'complete' ], true )
            && isset( $test['final_stats'] )
            && is_array( $test['final_stats'] )
            && isset( $test['final_stats']['A'], $test['final_stats']['B'] )
        ) {
            $out[ $test_id ] = $test['final_stats'];
            continue;
        }

        $cached = get_transient( abtestkit_pt_stats_cache_key( $test_id ) );
        if ( is_array( $cached ) && isset( $cached['A'], $cached['B'] ) ) {
            $out[ $test_id ] = $cached;
            continue;
        }

        $live_test_ids[] = $test_id;
    }

    if ( empty( $live_test_ids ) ) {
        return $out;
    }

    $table = ABTESTKIT_EVENTS_TABLE;
    $live_test_ids_csv = implode(
        ',',
        array_map(
            static function( $test_id ) {
                return abtestkit_sanitize_test_id( (string) $test_id );
            },
            $live_test_ids
        )
    );

    if ( abtestkit_events_table_has_column( 'amount' ) ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT ab_test_id, variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
                'FROM %i ' .
                'WHERE FIND_IN_SET(ab_test_id, %s) > 0 ' .
                "AND event_type IN ('impression','click','purchase') " .
                'GROUP BY ab_test_id, variant, event_type',
                $table,
                $live_test_ids_csv
            ),
            ARRAY_A
        );
    } else {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT ab_test_id, variant, event_type, COUNT(*) AS c, 0 AS revenue ' .
                'FROM %i ' .
                'WHERE FIND_IN_SET(ab_test_id, %s) > 0 ' .
                "AND event_type IN ('impression','click','purchase') " .
                'GROUP BY ab_test_id, variant, event_type',
                $table,
                $live_test_ids_csv
            ),
            ARRAY_A
        );
    }

    foreach ( $live_test_ids as $test_id ) {
        $out[ $test_id ] = abtestkit_pt_stats_default();
    }

    foreach ( (array) $rows as $row ) {
        $row_test_id = isset( $row['ab_test_id'] ) ? (string) $row['ab_test_id'] : '';
        $variant     = isset( $row['variant'] ) ? (string) $row['variant'] : '';
        $type        = isset( $row['event_type'] ) ? (string) $row['event_type'] : '';
        $count       = isset( $row['c'] ) ? (int) $row['c'] : 0;
        $revenue     = isset( $row['revenue'] ) ? (float) $row['revenue'] : 0.0;

        if ( $row_test_id === '' || ! isset( $out[ $row_test_id ] ) ) {
            continue;
        }

        if ( ! in_array( $variant, [ 'A', 'B' ], true ) ) {
            continue;
        }

        if ( $type === 'impression' ) {
            $out[ $row_test_id ][ $variant ]['impressions'] = $count;
        } elseif ( $type === 'click' ) {
            $out[ $row_test_id ][ $variant ]['clicks'] = $count;
        } elseif ( $type === 'purchase' ) {
            $out[ $row_test_id ][ $variant ]['purchases'] = $count;
            $out[ $row_test_id ][ $variant ]['revenue']   = round( $revenue, 2 );
        }
    }

    foreach ( $live_test_ids as $test_id ) {
        foreach ( [ 'A', 'B' ] as $variant ) {
            $purchases = (int) $out[ $test_id ][ $variant ]['purchases'];
            $revenue   = (float) $out[ $test_id ][ $variant ]['revenue'];

            $out[ $test_id ][ $variant ]['revenue_per_customer'] = $purchases > 0
                ? round( $revenue / $purchases, 2 )
                : 0.0;
        }

        set_transient( abtestkit_pt_stats_cache_key( $test_id ), $out[ $test_id ], 5 * MINUTE_IN_SECONDS );
    }

    return $out;
}

function abtestkit_pt_stats( array $test ) : array {
    // If Winner/Complete and we have a snapshot, ALWAYS use it.
    $status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
    if ( in_array( $status, [ 'winner', 'complete' ], true )
        && isset( $test['final_stats'] )
        && is_array( $test['final_stats'] )
        && isset( $test['final_stats']['A'], $test['final_stats']['B'] )
    ) {
        return $test['final_stats'];
    }

    global $wpdb;

    $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
    $test_id    = isset( $test['id'] ) ? (string) $test['id'] : '';

    if ( $control_id <= 0 || $test_id === '' ) {
        return abtestkit_pt_stats_default();
    }

    $cache_key = abtestkit_pt_stats_cache_key( $test_id );
    $cached    = get_transient( $cache_key );

    if ( is_array( $cached ) && isset( $cached['A'], $cached['B'] ) ) {
        return $cached;
    }

    $table = ABTESTKIT_EVENTS_TABLE;

    if ( abtestkit_events_table_has_column( 'amount' ) ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT variant, event_type, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS revenue ' .
                'FROM %i ' .
                'WHERE ab_test_id = %s ' .
                "AND event_type IN ('impression','click','purchase') " .
                'GROUP BY variant, event_type',
                $table,
                $test_id
            ),
            ARRAY_A
        );
    } else {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT variant, event_type, COUNT(*) AS c, 0 AS revenue ' .
                'FROM %i ' .
                'WHERE ab_test_id = %s ' .
                "AND event_type IN ('impression','click','purchase') " .
                'GROUP BY variant, event_type',
                $table,
                $test_id
            ),
            ARRAY_A
        );
    }

    $out = abtestkit_pt_stats_default();

    foreach ( (array) $rows as $r ) {
        $v       = isset( $r['variant'] ) ? (string) $r['variant'] : '';
        $t       = isset( $r['event_type'] ) ? (string) $r['event_type'] : '';
        $c       = isset( $r['c'] ) ? (int) $r['c'] : 0;
        $revenue = isset( $r['revenue'] ) ? (float) $r['revenue'] : 0.0;

        if ( ! isset( $out[ $v ] ) ) {
            continue;
        }

        if ( $t === 'purchase' ) {
            $out[ $v ]['purchases'] += $c;
            $out[ $v ]['revenue']   += $revenue;
        } elseif ( isset( $out[ $v ][ $t . 's' ] ) ) {
            $out[ $v ][ $t . 's' ] += $c;
        }
    }

    foreach ( [ 'A', 'B' ] as $variant ) {
        $purchases = (int) $out[ $variant ]['purchases'];
        $revenue   = (float) $out[ $variant ]['revenue'];

        $out[ $variant ]['revenue'] = round( $revenue, 2 );
        $out[ $variant ]['revenue_per_customer'] = $purchases > 0 ? round( $revenue / $purchases, 2 ) : 0.0;
    }

    set_transient( $cache_key, $out, 5 * MINUTE_IN_SECONDS );

    return $out;
}

/**
 * Dashboard helper: ask the existing /evaluate logic who is winning.
 * Returns 'A', 'B', or '' (no winner yet).
 */
/**
 * Dashboard helper: return the winner for a PT test.
 * If we already locked a winner, use that; otherwise fall back to /evaluate.
 */
function abtestkit_pt_winner_for_dashboard( array $test ) : string {
    // If we already locked a winner, always show it.
    $locked = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : '';
    if ( in_array( $locked, [ 'A', 'B' ], true ) ) {
        return $locked;
    }

    // IMPORTANT: do not evaluate paused/draft/complete tests.
    // This prevents paused/draft tests from appearing as "Winner" in the dashboard UI.
    $status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
    if ( $status !== 'running' ) {
        return '';
    }

    $req = new WP_REST_Request( 'GET', '/abtestkit/v1/evaluate' );
    $req->set_param( 'abTestId', (string) ( $test['id'] ?? '' ) );
    $req->set_param( 'post_id',  (int) ( $test['control_id'] ?? 0 ) );

    $res  = abtestkit_handle_evaluate( $req );
    $data = ( $res instanceof WP_REST_Response ) ? $res->get_data() : (array) $res;

    $winner = isset( $data['winner'] ) ? sanitize_text_field( (string) $data['winner'] ) : '';
    return in_array( $winner, [ 'A', 'B' ], true ) ? $winner : '';
}

/**
 * When the Bayesian evaluator declares a winner, lock it in and pause the test.
 * (This is the "automatic decision making" moment.)
 */
function abtestkit_pt_maybe_lock_winner( string $test_id ) : void {
    if ( strpos( $test_id, 'pt-' ) !== 0 ) {
        return;
    }

    $test = abtestkit_pt_get( $test_id );
    if ( ! is_array( $test ) ) {
        return;
    }

    // Only lock while running; only lock once.
    if ( ( $test['status'] ?? 'paused' ) !== 'running' ) {
        return;
    }

    // Manual mode: never auto-lock a winner.
    // Guard both decision_mode and decision_rule (older/newer tests may set either).
    if ( ( $test['decision_mode'] ?? 'auto' ) === 'manual' || ( $test['decision_rule'] ?? '' ) === 'manual' ) {
        return;
    }

    $already = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : '';
    if ( in_array( $already, [ 'A', 'B' ], true ) ) {
        return;
    }

    $control_id = (int) ( $test['control_id'] ?? 0 );
    if ( $control_id <= 0 ) {
        return;
    }

    $req = new WP_REST_Request( 'GET', '/abtestkit/v1/evaluate' );
    $req->set_param( 'abTestId', $test_id );
    $req->set_param( 'post_id',  $control_id );

    $res  = abtestkit_handle_evaluate( $req );
    $data = ( $res instanceof WP_REST_Response ) ? $res->get_data() : (array) $res;

    $winner = isset( $data['winner'] ) ? sanitize_text_field( (string) $data['winner'] ) : '';
    if ( ! in_array( $winner, [ 'A', 'B' ], true ) ) {
        return;
    }

    $test['winner']    = $winner;
    $test['winner_at'] = time();
    $test['status']    = 'winner'; // Paused automatically, but with an explicit Winner state.

    abtestkit_pt_snapshot_completed_test( $test );
    abtestkit_pt_put( $test );
    delete_transient( abtestkit_pt_stats_cache_key( (string) $test['id'] ) );
}

// Dashboard Render //
function abtestkit_render_page_tests_dashboard() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="wrap abtestkit-dashboard">
        <h1><?php esc_html_e( 'abtestkit Dashboard', 'abtestkit' ); ?></h1>
        <div id="abtestkit-dashboard-root"></div>
    </div>
    <?php
}



function abtestkit_render_pt_wizard_page() {
    if ( ! current_user_can('manage_options') ) return;
    echo '<div class="wrap"><h1>' . esc_html__( 'Create A/B Test', 'abtestkit' ) . '</h1><div id="abtestkit-pt-wizard-root"></div></div>';
}

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

    echo '<div class="wrap abtestkit-admin-page abtestkit-test-performance">';
    echo '<div id="abtestkit-test-performance-root"></div>';
    echo '</div>';
}

// Enqueue wizard assets on the Create A/B Test page
add_action( 'admin_enqueue_scripts', function( $hook ) {
    // Much simpler: just check the page slug in the URL.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['page'] ) || $_GET['page'] !== 'abtestkit-pt-wizard' ) {
        return;
    }

    // Telemetry (opt-in): first time opening Create Test wizard (and first time after first test)
    if ( function_exists( 'abtestkit_telemetry_track_pt_wizard_opened' ) ) {
        abtestkit_telemetry_track_pt_wizard_opened();
    }

    // Load the WordPress editor stack so we can embed classic editors in the wizard
    if ( function_exists( 'wp_enqueue_editor' ) ) {
        wp_enqueue_editor();
    } else {
        wp_enqueue_script( 'wp-editor' );
    }

    // React + components
    wp_enqueue_script( 'wp-element' );
    wp_enqueue_script( 'wp-components' );
    wp_enqueue_script( 'wp-api-fetch' );
    wp_enqueue_style( 'wp-components' );

    // Needed for wp.media() (image/gallery pickers in the Page Test wizard)
    wp_enqueue_media();

    // The actual wizard app
    wp_enqueue_script(
        'abtestkit-pt-wizard',
        plugins_url( 'assets/js/pt-wizard.js', __FILE__ ),
        [ 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-editor' ],
        '1.3.3',
        true
    );

    wp_localize_script( 'abtestkit-pt-wizard', 'abtestkit_PT', [
        'nonce'     => wp_create_nonce( 'wp_rest' ),
        'rest'      => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
        'dashboard' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
        'editBase'  => admin_url( 'post.php?post=' ),
        'viewBase'  => home_url( '/?p=' ),
    ] );
} );

// Enqueue JS admin list guard
add_action( 'admin_enqueue_scripts', function ( $hook ) {
    if ( ! in_array( $hook, [ 'edit.php' ], true ) ) {
        return;
    }

    wp_enqueue_script(
        'abtestkit-admin-list-guard',
        plugins_url( 'assets/js/admin-list-guard.js', __FILE__ ),
        [ 'jquery' ],
        ( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.3.3' ),
        true
    );

    // Build a map of post_id => status for running tests only
    $map = [];

    foreach ( abtestkit_pt_all() as $t ) {
        if ( empty( $t['status'] ) || $t['status'] !== 'running' ) {
            continue;
        }

        if ( ! empty( $t['control_id'] ) ) {
            $map[ (int) $t['control_id'] ] = 'running';
        }

        if ( ! empty( $t['variant_id'] ) ) {
            $map[ (int) $t['variant_id'] ] = 'running';
        }
    }

    wp_localize_script(
        'abtestkit-admin-list-guard',
        'ABTESTKIT_RUNNING_MAP',
        $map
    );
});

add_action( 'admin_enqueue_scripts', function( $hook ) {
    if ( 'plugins.php' !== $hook ) {
        return;
    }

    wp_enqueue_script(
        'abtestkit-delete-reason',
        plugins_url( 'assets/js/delete-reason.js', __FILE__ ),
        [],
        filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/delete-reason.js' ),
        true
    );

    wp_localize_script(
        'abtestkit-delete-reason',
        'ABTESTKIT_DELETE_REASON',
        [
            'rest'         => esc_url_raw( rest_url( 'abtestkit/v1/delete-reason' ) ),
            'nonce'        => wp_create_nonce( 'wp_rest' ),
            'pluginBase'   => plugin_basename( __FILE__ ),
            'title'        => __( 'Before you deactivate abtestkit', 'abtestkit' ),
            'label'        => __( 'Is something wrong that we could improve?', 'abtestkit' ),
            'detailLabel'  => __( 'Anything else you would like to tell us?', 'abtestkit' ),
            'confirmText'  => __( 'Send feedback and deactivate', 'abtestkit' ),
            'skipText'     => __( 'Skip and deactivate', 'abtestkit' ),
            'cancelText'   => __( 'Cancel', 'abtestkit' ),
            'requiredText' => __( 'Please select a reason before continuing.', 'abtestkit' ),
            'options'      => [
                [ 'value' => 'temporary',         'label' => __( 'I am deactivating it temporarily', 'abtestkit' ) ],
                [ 'value' => 'not_working',       'label' => __( 'It was not working correctly', 'abtestkit' ) ],
                [ 'value' => 'missing_feature',   'label' => __( 'It is missing a feature I need', 'abtestkit' ) ],
                [ 'value' => 'too_complex',       'label' => __( 'It was too difficult or confusing to use', 'abtestkit' ) ],
                [ 'value' => 'found_alternative', 'label' => __( 'I found a better alternative', 'abtestkit' ) ],
                [ 'value' => 'no_longer_needed',  'label' => __( 'I no longer need A/B testing', 'abtestkit' ) ],
                [ 'value' => 'other',             'label' => __( 'Other', 'abtestkit' ) ],
            ],
        ]
    );
} );

// Enqueue dashboard/performance admin apps
add_action( 'admin_enqueue_scripts', function( $hook ) {
    $is_dashboard_page = (
        $hook === 'toplevel_page_abtestkit-dashboard'
        || $hook === 'abtestkit-dashboard_page_abtestkit-dashboard'
    );

    $is_performance_page = ( $hook === 'admin_page_abtestkit-test-performance' );

    if ( ! $is_dashboard_page && ! $is_performance_page ) {
        return;
    }

    // Core deps from WordPress
    wp_enqueue_script( 'wp-element' );
    wp_enqueue_script( 'wp-components' );
    wp_enqueue_script( 'wp-api-fetch' );
    wp_enqueue_style( 'wp-components' );

    if ( $is_dashboard_page ) {
        wp_enqueue_script(
            'abtestkit-dashboard-app',
            plugins_url( 'assets/js/dashboard.js', __FILE__ ),
            [ 'wp-element', 'wp-components', 'wp-api-fetch' ],
            filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/dashboard.js' ),
            true
        );

        $conflict_banner = '';
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if ( isset( $_GET['error'] ) && $_GET['error'] === 'conflict_running' ) {
            $conflicts = [];
            if ( ! empty( $_GET['conflicts'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                $conflicts = array_filter(
                    array_map(
                        'sanitize_text_field',
                        explode( ',', (string) wp_unslash( $_GET['conflicts'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
                    )
                );
            }

            $base = __( 'This page is already in a running test.', 'abtestkit' );
            if ( ! empty( $conflicts ) ) {
                $base .= ' ' . sprintf(
                    /* translators: %s is a comma-separated list of conflicting test IDs. */
                    __( 'Conflicting test ID(s): %s.', 'abtestkit' ),
                    implode( ', ', $conflicts )
                );
            }
            $conflict_banner = $base;
        }

        wp_localize_script( 'abtestkit-dashboard-app', 'abtestkitDashboard', [
            'nonce'          => wp_create_nonce( 'wp_rest' ),
            'rest'           => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
            'createUrl'      => admin_url( 'admin.php?page=abtestkit-pt-wizard' ),
            'adminAction'    => admin_url( 'admin-post.php?action=abtestkit_pt_action' ),
            'adminNonce'     => wp_create_nonce( 'abtestkit_pt_action' ),
            'conflictBanner' => $conflict_banner,
        ] );
    }

    if ( $is_performance_page ) {
        wp_enqueue_script(
            'abtestkit-test-performance',
            plugins_url( 'assets/js/test-performance.js', __FILE__ ),
            [ 'wp-element', 'wp-components', 'wp-api-fetch' ],
            filemtime( plugin_dir_path( __FILE__ ) . 'assets/js/test-performance.js' ),
            true
        );

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        wp_localize_script( 'abtestkit-test-performance', 'abtestkitTestPerformance', [
            'nonce'        => wp_create_nonce( 'wp_rest' ),
            'rest'         => esc_url_raw( rest_url( 'abtestkit/v1' ) ),
            'testId'       => '',
            'dashboardUrl' => admin_url( 'admin.php?page=abtestkit-dashboard' ),
            'adminAction'  => admin_url( 'admin-post.php?action=abtestkit_pt_action' ),
            'adminNonce'   => wp_create_nonce( 'abtestkit_pt_action' ),
        ] );
    }
} );

function abtestkit_enqueue_admin_assets( $hook ) {
    // Only load on your AB Test Kit wizard page or post type page
    if ( strpos( $hook, 'abtestkit' ) === false ) {
        return;
    }

    wp_enqueue_style(
        'abtestkit-admin',
        plugin_dir_url( __FILE__ ) . 'assets/css/admin.css',
        [],
        filemtime( plugin_dir_path( __FILE__ ) . 'assets/css/admin.css' )
    );
}
add_action( 'admin_enqueue_scripts', 'abtestkit_enqueue_admin_assets' );


// ── Assignment + redirect + server-side impression logging ───────────────────
add_action( 'template_redirect', function () {
    // Exempt viewers (admins etc): never load frontend tracking JS.
    if ( abtestkit_is_exempt_viewer() ) {
        return;
    }

    // No tracking in admin, feeds, embeds, or non-singular views.
    if ( is_admin() || is_feed() || is_embed() ) {
        return;
    }

    if ( ! is_singular() ) {
        return;
    }

    $post_id   = (int) get_queried_object_id();
    $post_type = get_post_type( $post_id );

    if ( ! $post_id || ! $post_type ) {
        return;
    }

    // See if this page/product belongs to a Page Test.
    [ $test, $role ] = abtestkit_pt_find_by_post( $post_id );
    if ( ! $test ) {
        return;
    }

    // Product tests keep a single URL and use "virtual B" overrides instead of redirects.
    $is_product_test = ( $post_type === 'product' && ( $test['kind'] ?? '' ) === 'product' );

    // phpcs:disable WordPress.Security.NonceVerification.Recommended
    $is_preview = ( isset( $_GET['abtestkit_preview'] ) && sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview'] ) ) === '1' );
    // phpcs:enable WordPress.Security.NonceVerification.Recommended

    $cookie_name = 'abtestkit_pt_' . (string) $test['id'];
    $ttl         = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) );
    $split       = max( 0, min( 100, (int) ( $test['split'] ?? 50 ) ) );

    /**
     * Running full-page tests neve cache.
     * Otherwise a cached 302 or cached HTML page can pin everyone to one variant.
     */
    // These are standard interoperability constants recognised by major caching/minification plugins.
    // They must use their canonical names and must not be plugin-prefixed.
    // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
    if ( ! defined( 'DONOTCACHEPAGE' ) ) {
        define( 'DONOTCACHEPAGE', true );
    }
    if ( ! defined( 'DONOTCACHEDB' ) ) {
        define( 'DONOTCACHEDB', true );
    }
    if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
        define( 'DONOTCACHEOBJECT', true );
    }
    if ( ! defined( 'DONOTMINIFY' ) ) {
        define( 'DONOTMINIFY', true );
    }
    // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound

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

    /**
     * HTTP must never:
     * - assign a live variant
     * - bounce into the HTTPS test flow
     * - write an impression/click row
     *
     * Force control for this request and stop here.
     */
    if ( ! abtestkit_request_is_real_https() ) {
        $GLOBALS['abtestkit_current_pt_assignment'] = [
            'test'      => $test,
            'variant'   => 'A',
            'role'      => $role,
            'post_id'   => $post_id,
            'protocol'  => 'http',
            'no_track'  => true,
            'forced_a'  => true,
        ];

        if ( ! $is_product_test && $role === 'variant' && ! $is_preview ) {
            $control_http_url = set_url_scheme( get_permalink( (int) $test['control_id'] ), 'http' );
            wp_safe_redirect( $control_http_url, 302 );
            exit;
        }

        return;
    }

    /**
     * HTTPS only:
     * if someone lands directly on the Version B URL for a PAGE/POST test,
     * route them via the control URL so assignment happens first.
     */
    if ( ! $is_product_test && $role === 'variant' && ! $is_preview ) {
        $assigned_raw = '';

        if ( isset( $_COOKIE[ $cookie_name ] ) ) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
            $assigned_raw = wp_unslash( $_COOKIE[ $cookie_name ] );
        }

        $assigned = ( '' !== $assigned_raw ) ? sanitize_text_field( $assigned_raw ) : '';

        if ( 'A' !== $assigned && 'B' !== $assigned ) {
            wp_safe_redirect( get_permalink( (int) $test['control_id'] ), 302 );
            exit;
        }
    }

    // Exempt viewers (admins etc): always see Version A, never counted.
    if ( abtestkit_is_exempt_viewer() ) {
        if ( $role === 'variant' && ! $is_product_test ) {
            wp_safe_redirect( get_permalink( (int) $test['control_id'] ), 302 );
            exit;
        }
        return;
    }

    // Assignment: cookie → else random by split.
    $assigned = abtestkit_safe_get_cookie_value( $cookie_name );

    // Only accept valid values; otherwise assign and persist it safely.
    if ( 'A' !== $assigned && 'B' !== $assigned ) {
        $assigned = ( wp_rand( 1, 100 ) <= (int) $split ) ? 'B' : 'A';
        abtestkit_safe_set_cookie(
            $cookie_name,
            $assigned,
            time() + ( (int) $ttl ) * DAY_IN_SECONDS
        );

        // Keep this same request aligned immediately.
        $_COOKIE[ $cookie_name ] = $assigned;
    }

    // Expose current assignment globally so other hooks (Woo overrides) can read it.
    $GLOBALS['abtestkit_current_pt_assignment'] = [
        'test'      => $test,
        'variant'   => $assigned,
        'role'      => $role,
        'post_id'   => $post_id,
        'protocol'  => 'https',
        'no_track'  => false,
        'forced_a'  => false,
    ];

    // Redirect to the correct variant if the landing page mismatches assignment.
    if ( ! $is_product_test ) {
        if ( $assigned === 'B' && $role === 'control' && ! empty( $test['variant_id'] ) ) {
            wp_safe_redirect( get_permalink( (int) $test['variant_id'] ), 302 );
            exit;
        }
        if ( $assigned === 'A' && $role === 'variant' ) {
            wp_safe_redirect( get_permalink( (int) $test['control_id'] ), 302 );
            exit;
        }
    }

}, 1 );


function abtestkit_pt_status_priority( string $status ) : int {
    // Lower number = higher priority
    if ( $status === 'running' )  return 0;
    if ( $status === 'paused' )   return 1;
    if ( $status === 'draft' )    return 2;
    if ( $status === 'complete' ) return 9;
    return 5;
}

function abtestkit_pt_pick_best_test_for_control( array $tests, int $control_id ) : ?array {
    $matches = [];

    foreach ( $tests as $t ) {
        if ( ! is_array( $t ) ) continue;
        if ( ( $t['kind'] ?? '' ) !== 'product' ) continue;
        if ( (int)( $t['control_id'] ?? 0 ) !== $control_id ) continue;
        $matches[] = $t;
    }

    if ( empty( $matches ) ) return null;

    usort( $matches, function( $a, $b ) {

        $pa = abtestkit_pt_status_priority( (string)($a['status'] ?? '') );
        $pb = abtestkit_pt_status_priority( (string)($b['status'] ?? '') );

        if ( $pa !== $pb ) {
            return $pa <=> $pb;
        }

        $sa = (int) ( $a['started_at'] ?? 0 );
        $sb = (int) ( $b['started_at'] ?? 0 );

        if ( $sa !== $sb ) {
            return $sb <=> $sa; // newest first
        }

        return strcmp(
            (string)($b['id'] ?? ''),
            (string)($a['id'] ?? '')
        );
    });

    return $matches[0];
}

/**
 * Helper: get the active product test (if any) and the viewer's variant
 * for a given WooCommerce product.
 */
function abtestkit_get_active_product_test_for_product( $product ) : array {
    if ( ! function_exists( 'wc_get_product' ) ) {
        return [ null, '' ];
    }

    // Allow explicit preview overrides for admin/exempt viewers when using the
    // dashboard preview links (abtestkit_preview=1&abtestkit_force=A|B).
    $force_variant = '';
    $preview_flag  = '';
    $raw_force     = '';

    // Read-only GET flags; no state is changed here.
    // phpcs:disable WordPress.Security.NonceVerification.Recommended
    if ( isset( $_GET['abtestkit_preview'] ) ) {
        $preview_flag = sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_preview'] ) );
    }

    if ( isset( $_GET['abtestkit_force'] ) ) {
        $raw_force = strtoupper( sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_force'] ) ) );
    }
    // phpcs:enable WordPress.Security.NonceVerification.Recommended

    if ( $preview_flag === '1' && ( $raw_force === 'A' || $raw_force === 'B' ) ) {
        $force_variant = $raw_force;
    }

    // If we're previewing Version B for a product *before the test exists*,
    // allow passing a temporary preview token containing overrides.
    $preview_token = '';

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( isset( $_GET['abtestkit_token'] ) ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $preview_token = sanitize_text_field( wp_unslash( (string) $_GET['abtestkit_token'] ) );
    }

    if ( $force_variant === 'B' && $preview_token ) {
        $key  = 'abtestkit_product_preview_' . $preview_token;
        $data = get_transient( $key );

        if ( is_array( $data ) && ! empty( $data['overrides'] ) ) {
            // Ensure the preview token matches this product ID.
            // $product can be a WC_Product OR a numeric product ID depending on hook context.
            $pid = 0;

            if ( $product instanceof WC_Product ) {
                $pid = (int) $product->get_id();
            } elseif ( is_numeric( $product ) ) {
                $pid = absint( $product );
            } elseif ( is_object( $product ) && method_exists( $product, 'get_id' ) ) {
                $pid = (int) $product->get_id();
            } else {
                // Last-ditch attempt: try to normalise to a WC_Product
                if ( function_exists( 'wc_get_product' ) ) {
                    $maybe = wc_get_product( $product );
                    if ( $maybe instanceof WC_Product ) {
                        $pid = (int) $maybe->get_id();
                    }
                }
            }

            if ( $pid > 0 && isset( $data['control_id'] ) && (int) $data['control_id'] === $pid ) {
                $fake_test = [
                    'id'               => 'preview-' . $preview_token,
                    'status'           => 'preview',
                    'goal'             => 'add_to_cart',
                    'control_id'        => $pid,
                    'variant_id'        => 0,
                    'min_conversions'   => 5,
                    'overrides'         => (array) $data['overrides'],
                    'product_overrides' => (array) $data['overrides'],
                ];

                // Return as "variant" so your existing override filters apply.
                return [ $fake_test, 'B' ];
            }
        }
    }

    // Never apply product test overrides inside wp-admin (product list, editor, etc.)
    // or for exempt viewers (typically admins with manage_options),
    // UNLESS we are in an explicit preview with a forced variant.
    if ( $force_variant === '' && ( is_admin() || abtestkit_is_exempt_viewer() ) ) {
        return [ null, '' ];
    }

    // Normalise to a WC_Product.
    if ( $product instanceof WC_Product ) {
        $wc_product = $product;
    } else {
        $wc_product = wc_get_product( $product );
        if ( ! $wc_product ) {
            return [ null, '' ];
        }
    }

    $product_id = (int) $wc_product->get_id();

    // If we're forcing A/B via preview link, allow resolving paused tests too.
    if ( $force_variant !== '' ) {
        [ $test, $role ] = abtestkit_pt_find_by_post_any_status( $product_id );
    } else {
        [ $test, $role ] = abtestkit_pt_find_by_post( $product_id );
    }

    if ( ! $test || ( $test['kind'] ?? '' ) !== 'product' ) {
        // Wizard pre-save preview: if an admin forces Version B before the test exists,
        // use the last duplicated shadow product created in this wizard session.
        if ( $force_variant === 'B' ) {
            $shadow_id = abtestkit_pt_get_last_duplicate_for_user( $product_id, (int) get_current_user_id() );

            if (
                $shadow_id
                && abtestkit_is_shadow_product( (int) $shadow_id )
                && (int) get_post_meta( (int) $shadow_id, '_abtestkit_shadow_of', true ) === (int) $product_id
            ) {
                $fake_test = [
                    'id'              => 'preview-shadow-' . (int) $shadow_id,
                    'status'          => 'preview',
                    'goal'            => 'add_to_cart',
                    'control_id'      => (int) $product_id,
                    'variant_id'      => (int) $shadow_id,
                    'kind'            => 'product',
                    'min_conversions' => 5,
                ];

                return [ $fake_test, 'B' ];
            }
        }

        return [ null, '' ];
    }

    // If we're on a preview with an explicit forced variant, honour it and bail.
    if ( $force_variant === 'A' || $force_variant === 'B' ) {
        return [ $test, $force_variant ];
    }

    // Live product tests must never assign or render Version B on HTTP.
    // HTTP is diagnostics-only and always treated as control/no-track.
    if ( ! abtestkit_request_is_real_https() ) {
        return [ $test, 'A' ];
    }

    $variant = '';

    // 1) Prefer the assignment that template_redirect already made for this request.
    if (
        isset( $GLOBALS['abtestkit_current_pt_assignment'] )
        && is_array( $GLOBALS['abtestkit_current_pt_assignment'] )
    ) {
        $ctx       = $GLOBALS['abtestkit_current_pt_assignment'];
        $ctx_test  = $ctx['test']    ?? null;
        $ctx_var   = $ctx['variant'] ?? '';
        $ctx_id    = ( is_array( $ctx_test ) && isset( $ctx_test['id'] ) ) ? (string) $ctx_test['id'] : '';

        if ( $ctx_id !== '' && $ctx_id === (string) $test['id'] ) {
            $variant = $ctx_var;
        }
    }

    // 2) Fallback to the cookie (archives, menus, carts, or if global isn't set).
    if ( $variant !== 'A' && $variant !== 'B' ) {
        $cookie_name = 'abtestkit_pt_' . (string) $test['id'];
        $cookie_val  = '';

        if ( isset( $_COOKIE[ $cookie_name ] ) ) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $cookie_raw = wp_unslash( $_COOKIE[ $cookie_name ] );
            $cookie_val = sanitize_text_field( $cookie_raw );
        }

        if ( $cookie_val === 'A' || $cookie_val === 'B' ) {
            // Existing assignment – just reuse it.
            $variant = $cookie_val;
        } else {
            // No cookie yet (first ever view, often from a category/archive).
            // Assign now using the same split + TTL rules as template_redirect,
            // and write the cookie so subsequent views stay consistent.
            $ttl   = max( 1, (int) ( $test['cookie_ttl_days'] ?? 30 ) );
            $split = max( 0, min( 100, (int) ( $test['split'] ?? 50 ) ) );

            // Same rule as template_redirect: random A/B based on "split" (chance to show B).
            $assigned = ( wp_rand( 1, 100 ) <= (int) $split ) ? 'B' : 'A';

            abtestkit_safe_set_cookie(
                $cookie_name,
                $assigned,
                time() + ( (int) $ttl ) * DAY_IN_SECONDS
            );

            // Keep the current request in sync even if the helper had to fall back
            // to Woo session because headers were already sent.
            $_COOKIE[ $cookie_name ] = $assigned;
            $variant                 = $assigned;
        }
    }

    return [ $test, $variant ];
}

/**
 * Prepare an override string exactly once:
 * - decode HTML entities (&#91;shortcode&#93; / &lt;div&gt; etc)
 * - normalise smart quotes
 * DO NOT run wpautop/shortcodes/blocks here — let the normal filter pipeline do that.
 */
function abtestkit_prepare_override_raw( $html ) : string {
    $out = (string) $html;

    $charset = function_exists( 'get_bloginfo' ) ? get_bloginfo( 'charset' ) : 'UTF-8';
    $out = html_entity_decode( $out, ENT_QUOTES | ENT_HTML5, $charset );

    $out = str_replace(
        [ "“", "”", "‘", "’" ],
        [ '"', '"', "'", "'" ],
        $out
    );

    return $out;
}

function abtestkit_render_product_override_html( $html ) : string {
    $out = abtestkit_prepare_override_raw( $html );

    // Render the override without using the_content, because Elementor can hijack it
    // and replace our override entirely with its own template output.
    if ( function_exists( 'do_blocks' ) ) {
        $out = do_blocks( $out );
    }

    $out = wpautop( $out );
    $out = shortcode_unautop( $out );
    $out = do_shortcode( $out );

    return $out;
}


// WooCommerce product field overrides for "virtual B" product tests.
add_action( 'plugins_loaded', function () {

    // When Variant B has a real variant_id (a duplicated product / Elementor document),
    // enqueue Elementor's per-post CSS for that variant so styling actually appears.
    add_action( 'wp_enqueue_scripts', function() {

        if ( is_admin() ) return;

        if ( ! function_exists( 'is_product' ) || ! is_product() ) {
            return;
        }

        if ( ! class_exists( '\Elementor\Plugin' ) ) {
            return; // Elementor not active
        }

        global $post;
        if ( ! ( $post instanceof WP_Post ) ) {
            return;
        }

        $product = function_exists( 'wc_get_product' ) ? wc_get_product( $post->ID ) : null;
        if ( ! $product ) {
            return;
        }

        [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
        if ( ! $test || $variant !== 'B' ) {
            return;
        }

        $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
        if ( $variant_id <= 0 ) {
            return; // "virtual B" preview tokens have variant_id = 0
        }

        // Base Elementor frontend styles.
        \Elementor\Plugin::instance()->frontend->enqueue_styles();

    }, 20 );

    if ( ! function_exists( 'wc_get_product' ) ) {
        // WooCommerce not active – nothing to do.
        return;
    }

        /**
     * Return the shadow WC_Product for a product test (Version B), or null.
     */
    function abtestkit_pt_get_shadow_product_for_test( $test ) {
        if ( ! is_array( $test ) ) return null;

        $vid = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
        if ( $vid <= 0 ) return null;

        static $cache = [];

        // Use array_key_exists so null is cached too.
        if ( array_key_exists( $vid, $cache ) ) {
            return $cache[ $vid ];
        }

        if ( get_post_type( $vid ) !== 'product' ) {
            $cache[ $vid ] = null;
            return null;
        }

        $p = null;

        // Fast path.
        if ( function_exists( 'wc_get_product' ) ) {
            $maybe = wc_get_product( $vid );
            if ( $maybe instanceof WC_Product ) {
                $p = $maybe;
            }
        }

        // Fallback: explicitly include non-public statuses.
        if ( ! $p && function_exists( 'wc_get_products' ) ) {
            $found = wc_get_products( [
                'include' => [ $vid ],
                'limit'   => 1,
                'status'  => [ 'publish', 'draft', 'pending', 'private', 'future' ],
                'return'  => 'objects',
            ] );

            if ( is_array( $found ) && ! empty( $found ) && $found[0] instanceof WC_Product ) {
                $p = $found[0];
            }
        }

        // Last resort: construct a WC_Product directly (bypasses frontend status restrictions).
        if ( ! $p && class_exists( 'WC_Product_Factory' ) ) {
            try {
                $factory = new WC_Product_Factory();

                $type = '';
                if ( method_exists( $factory, 'get_product_type' ) ) {
                    $type = (string) $factory->get_product_type( $vid );
                }
                if ( $type === '' ) {
                    $type = 'simple';
                }

                $classname = 'WC_Product_Simple';
                if ( method_exists( 'WC_Product_Factory', 'get_product_classname' ) ) {
                    $maybe_class = WC_Product_Factory::get_product_classname( $vid, $type );
                    if ( is_string( $maybe_class ) && $maybe_class !== '' && class_exists( $maybe_class ) ) {
                        $classname = $maybe_class;
                    }
                }

                $maybe = new $classname( $vid );
                if ( $maybe instanceof WC_Product ) {
                    $p = $maybe;
                }
            } catch ( \Throwable $e ) {
                // ignore
            }
        }

        $cache[ $vid ] = ( $p instanceof WC_Product ) ? $p : null;
        return $cache[ $vid ];
    }


    /**
     * Shadow products should never be purchasable or visible.
     */
    add_filter( 'woocommerce_is_purchasable', function( $purchasable, $product ) {
        $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : (int) $product;
        if ( $pid > 0 && (int) get_post_meta( $pid, '_abtestkit_shadow', true ) === 1 ) {
            return false;
        }
        return $purchasable;
    }, 10, 2 );

    add_filter( 'woocommerce_product_is_visible', function( $visible, $product_id ) {
        $pid = (int) $product_id;
        if ( $pid > 0 && (int) get_post_meta( $pid, '_abtestkit_shadow', true ) === 1 ) {
            return false;
        }
        return $visible;
    }, 10, 2 );

    // Noindex shadow products if they ever become accessible.
    add_filter( 'wp_robots', function( $robots ) {
        if ( function_exists( 'is_product' ) && is_product() ) {
            $pid = get_queried_object_id();
            if ( $pid > 0 && (int) get_post_meta( $pid, '_abtestkit_shadow', true ) === 1 ) {
                $robots['noindex'] = true;
                $robots['nofollow'] = true;
            }
        }
        return $robots;
    } );

// Name/title
add_filter( 'woocommerce_product_get_name', function ( $name, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    // Never overlay when Woo is *reading the shadow itself* (prevents recursion).
    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $name;
    }

    static $in = false;
    if ( $in ) return $name;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $name;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $name;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $name;

    // Use 'edit' to bypass Woo's view filters (prevents recursive filter calls).
    $n = $shadow->get_name( 'edit' );
    return $n !== '' ? $n : $name;

}, 10, 2 );

// Descriptions
add_filter( 'woocommerce_product_get_short_description', function( $val, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $val;
    }

    static $in = false;
    if ( $in ) return $val;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $val;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $val;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $val;

    $v = $shadow->get_short_description( 'edit' );
    return $v !== '' ? $v : $val;

}, 10, 2 );

add_filter( 'woocommerce_product_get_description', function( $val, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $val;
    }

    static $in = false;
    if ( $in ) return $val;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $val;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $val;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $val;

    $v = $shadow->get_description( 'edit' );
    return $v !== '' ? $v : $val;

}, 10, 2 );

// Images
add_filter( 'woocommerce_product_get_image_id', function( $image_id, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $image_id;
    }

    static $in = false;
    if ( $in ) return $image_id;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $image_id;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $image_id;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $image_id;

    $iid = (int) $shadow->get_image_id( 'edit' );
    return $iid > 0 ? $iid : $image_id;

}, 10, 2 );

add_filter( 'woocommerce_product_get_gallery_image_ids', function( $ids, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $ids;
    }

    static $in = false;
    if ( $in ) return $ids;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $ids;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $ids;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $ids;

    $g = $shadow->get_gallery_image_ids( 'edit' );
    return ( is_array( $g ) && ! empty( $g ) ) ? $g : $ids;

}, 10, 2 );

// ───────────────────────────────────────────────────────────
// Variant B "Shadow Overlay" (builder-agnostic, fast)
// - Keep REAL product (A) as the WooCommerce ID for SKU/stock/orders
// - Overlay *display* layer (meta/content/featured image/builder data/ACF/etc) from shadow product (B)
// - Enable only on single product pages AND only when visitor is Variant B
// ───────────────────────────────────────────────────────────

if ( ! function_exists( 'abtestkit_pt_shadow_ctx_get' ) ) {
    function abtestkit_pt_shadow_ctx_get() : array {
        $ctx = $GLOBALS['abtestkit_pt_shadow_ctx'] ?? null;
        return is_array( $ctx ) ? $ctx : [];
    }
}

if ( ! function_exists( 'abtestkit_pt_shadow_ctx_set' ) ) {
    function abtestkit_pt_shadow_ctx_set( int $control_id, int $shadow_id, array $test ) : void {
        $GLOBALS['abtestkit_pt_shadow_ctx'] = [
            'control_id' => $control_id,
            'shadow_id'  => $shadow_id,
            'test_id'    => isset( $test['id'] ) ? (string) $test['id'] : '',
        ];
    }
}

/**
 * Meta keys that must ALWAYS come from the real product (A).
 * These are the "commerce truth" keys that should never be shadowed.
 */
if ( ! function_exists( 'abtestkit_pt_shadow_protected_meta_keys' ) ) {
    function abtestkit_pt_shadow_protected_meta_keys() : array {
        return [
            '_sku',
            '_manage_stock',
            '_stock',
            '_stock_status',
            '_backorders',
            '_sold_individually',
            '_abtestkit_shadow',
            '_abtestkit_shadow_of',
        ];
    }
}

if ( ! function_exists( 'abtestkit_pt_shadow_get_post_metadata' ) ) {
function abtestkit_pt_shadow_get_post_metadata( $value, $object_id, $meta_key, $single ) {
        // Only frontend (AJAX is fine)
        if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
            return $value;
        }

        $ctx = abtestkit_pt_shadow_ctx_get();
        if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
            return $value;
        }

        $control_id = (int) $ctx['control_id'];
        $shadow_id  = (int) $ctx['shadow_id'];

        // Safety: never shadow onto itself (can cause recursion / weirdness)
        if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id ) {
            return $value;
        }

        $pid = (int) $object_id;
        if ( $pid !== $control_id ) {
            return $value; // only overlay the control product
        }

        static $guard = false;
        if ( $guard ) {
            return $value;
        }

        // If meta_key is empty, WP core is asking for the whole meta cache.
        // ACF (especially repeaters/flexible) often does this, so we must return the shadow's full meta array,
        // but keep "commerce truth" keys from the real product (A).
        if ( ! is_string( $meta_key ) || $meta_key === '' ) {
            $guard = true;

            // Fetch ALL meta arrays.
            $shadow_all  = get_post_meta( $shadow_id );
            $control_all = get_post_meta( $control_id );

            $guard = false;

            if ( ! is_array( $shadow_all ) ) {
                return $value;
            }

            // Start with shadow meta, then overwrite protected keys with control meta.
            foreach ( abtestkit_pt_shadow_protected_meta_keys() as $k ) {
                if ( isset( $control_all[ $k ] ) ) {
                    $shadow_all[ $k ] = $control_all[ $k ];
                } else {
                    unset( $shadow_all[ $k ] );
                }
            }

            // get_post_metadata filters must return an array for full meta.
            return $shadow_all;
        }

        // Never shadow "commerce truth" meta
        if ( in_array( $meta_key, abtestkit_pt_shadow_protected_meta_keys(), true ) ) {
            return $value;
        }

        // IMPORTANT: don't build our own cache here (it explodes memory).
        // WP already caches post meta internally via update_meta_cache().
        $guard = true;
        $shadow_val = get_post_meta( $shadow_id, $meta_key, $single );
        $guard = false;

        // If shadow has nothing meaningful for this key, keep original behaviour.
        if ( $single ) {
            if ( $shadow_val === '' || $shadow_val === null ) {
                return $value; // let WP fetch A normally
            }

            // IMPORTANT: WP core expects an ARRAY from get_post_metadata filters.
            // When $single=true, core returns $check[0], so index 0 must exist.
            return [ $shadow_val ];
        }

        return ( is_array( $shadow_val ) && ! empty( $shadow_val ) ) ? $shadow_val : $value;
    }
}

/**
 * ACF compatibility: force ACF to resolve field values against the shadow product ID
 * when a visitor is assigned to Variant B.
 *
 * ACF calls: apply_filters( 'acf/pre_load_post_id', null, $post_id );
 */
if ( ! function_exists( 'abtestkit_pt_shadow_acf_pre_load_post_id' ) ) {
    function abtestkit_pt_shadow_acf_pre_load_post_id( $null, $post_id ) {
        // Only frontend (AJAX is fine)
        if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
            return $null;
        }

        $ctx = abtestkit_pt_shadow_ctx_get();
        if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
            return $null;
        }

        $control_id = (int) $ctx['control_id'];
        $shadow_id  = (int) $ctx['shadow_id'];

        if ( $control_id <= 0 || $shadow_id <= 0 || $control_id === $shadow_id ) {
            return $null;
        }

        $pid = 0;

        if ( is_numeric( $post_id ) ) {
            $pid = (int) $post_id;
        } elseif ( is_string( $post_id ) && preg_match( '/_(\\d+)$/', $post_id, $m ) ) {
            $pid = (int) $m[1];
        }

        if ( $pid !== $control_id ) {
            return $null;
        }

        return $shadow_id;
    }
}

if ( ! function_exists( 'abtestkit_pt_shadow_the_content' ) ) {

    // Keep the filter priority in one place so we can safely remove/re-add it.
    if ( ! defined( 'ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY' ) ) {
        define( 'ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY', 9999 );
    }

    /**
     * Return a cache-busting revision token for a shadow product.
     * - post_modified is usually enough
     * - _abtestkit_rev is a lightweight “bump” value we can update on saves/meta edits
     */
    function abtestkit_pt_shadow_rev_token( int $shadow_id ) : string {
        $shadow_id = (int) $shadow_id;
        $mod = (string) get_post_modified_time( 'U', true, $shadow_id );
        $rev = (string) get_post_meta( $shadow_id, '_abtestkit_rev', true );
        if ( $rev === '' ) $rev = '0';
        return $mod . ':' . $rev;
    }

    function abtestkit_pt_shadow_cache_key( int $shadow_id, string $token ) : string {
        // token already changes when content/meta changes
        return 'abtestkit_pt_shadow_html_' . $shadow_id . '_' . md5( $token );
    }

    function abtestkit_pt_shadow_cache_get( string $key ) {
        // Prefer object cache if present; fallback to transient.
        $group = 'abtestkit';
        $val = wp_cache_get( $key, $group );
        if ( $val !== false && is_string( $val ) ) return $val;

        $val = get_transient( $key );
        if ( is_string( $val ) && $val !== '' ) {
            wp_cache_set( $key, $val, $group, 3600 );
            return $val;
        }
        return false;
    }

    function abtestkit_pt_shadow_cache_set( string $key, string $html ) : void {
        $group = 'abtestkit';
        // Keep it long; the key changes automatically when the shadow changes.
        set_transient( $key, $html, 30 * DAY_IN_SECONDS );
        wp_cache_set( $key, $html, $group, 3600 );
    }

    /**
     * Render the shadow product content through the normal WP pipeline (builder-agnostic).
     * This avoids Elementor’s heavy direct rendering APIs and prevents recursion by
     * temporarily removing our own swap filter.
     */
    function abtestkit_pt_render_shadow_content_for_display( int $shadow_id ) : string {
        $shadow_post = get_post( $shadow_id );
        if ( ! $shadow_post || empty( $shadow_post->post_content ) ) return '';

        $orig_post = $GLOBALS['post'] ?? null;

        // Switch global post context to the shadow.
        $GLOBALS['post'] = $shadow_post;
        setup_postdata( $shadow_post );

        // Prevent recursion / double swaps.
        remove_filter( 'the_content', 'abtestkit_pt_shadow_the_content', ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY );

        // Let Elementor/ACF/shortcodes/blocks run as they normally would for THIS post.
        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentionally invoking core 'the_content' filters to render builder content for the shadow post.
        $html = apply_filters( 'the_content', $shadow_post->post_content );

        // Restore.
        add_filter( 'the_content', 'abtestkit_pt_shadow_the_content', ABTESTKIT_PT_SHADOW_CONTENT_PRIORITY );
        wp_reset_postdata();

        if ( $orig_post ) {
            $GLOBALS['post'] = $orig_post;
        }

        return is_string( $html ) ? $html : '';
    }

    /**
     * Swap the final rendered product description/tab content to Version B.
     * Uses a cache key that automatically changes when the shadow product changes.
     */
function abtestkit_pt_shadow_the_content( $content ) {
    $ctx = abtestkit_pt_shadow_ctx_get();
    if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) {
        return $content;
    }

    if ( ! function_exists( 'is_product' ) || ! is_product() ) {
        return $content;
    }

    $current_id = (int) get_the_ID();
    if ( $current_id !== (int) $ctx['control_id'] ) {
        return $content;
    }

    /*
     * SAFETY:
     * Some WooCommerce themes/builders use the_content() as part of the main
     * single-product template output, not just the long description/tab content.
     * In those cases, swapping the entire content with the shadow post_content
     * can wipe the gallery / price / add-to-cart area.
     *
     * So for product tests, do NOT hard-swap the_content here.
     * Shadow title / excerpt / short description / price / sale price / meta
     * are already handled by the dedicated product/shadow filters.
     */
    return $content;
}

    /**
     * Bump a lightweight revision flag whenever a shadow product is saved.
     * This avoids needing to diff meta (ACF etc.) — cache key changes automatically.
     */
    add_action( 'save_post_product', function( $post_id, $post, $update ) {
        $post_id = (int) $post_id;
        if ( $post_id <= 0 ) return;

        // Only for shadow products.
        if ( function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $post_id ) ) {
            update_post_meta( $post_id, '_abtestkit_rev', (string) time() );
        }
    }, 20, 3 );

    // If ACF is installed, bump on ACF saves too (covers edge cases).
    if ( function_exists( 'acf_add_action' ) ) {
        add_action( 'acf/save_post', function( $post_id ) {
            $pid = is_numeric( $post_id ) ? (int) $post_id : 0;
            if ( $pid <= 0 ) return;

            if ( function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
                update_post_meta( $pid, '_abtestkit_rev', (string) time() );
            }
        }, 20 );
    }
}

/**
 * Enable Version B (shadow product) when needed.
 * IMPORTANT:
 * - Hook on 'wp' (earlier than template_redirect) so Elementor/meta consumers see the shadowed meta.
 * - Also shadow WP fields used by Woo templates: title + excerpt.
 */
if ( ! function_exists( 'abtestkit_pt_shadow_the_title' ) ) {
    function abtestkit_pt_shadow_the_title( $title, $post_id ) {
        if ( is_admin() ) return $title;
        if ( ! function_exists( 'is_product' ) || ! is_product() ) return $title;

        $ctx = abtestkit_pt_shadow_ctx_get();
        if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $title;

        if ( (int) $post_id !== (int) $ctx['control_id'] ) return $title;

        $shadow_post = get_post( (int) $ctx['shadow_id'] );
        if ( $shadow_post && isset( $shadow_post->post_title ) && $shadow_post->post_title !== '' ) {
            return $shadow_post->post_title;
        }

        return $title;
    }
}

if ( ! function_exists( 'abtestkit_pt_shadow_get_the_excerpt' ) ) {
    function abtestkit_pt_shadow_get_the_excerpt( $excerpt, $post ) {
        if ( is_admin() ) return $excerpt;
        if ( ! function_exists( 'is_product' ) || ! is_product() ) return $excerpt;

        $ctx = abtestkit_pt_shadow_ctx_get();
        if ( empty( $ctx['control_id'] ) || empty( $ctx['shadow_id'] ) ) return $excerpt;

        $pid = 0;
        if ( $post instanceof WP_Post ) {
            $pid = (int) $post->ID;
        } elseif ( is_numeric( $post ) ) {
            $pid = (int) $post;
        } else {
            $pid = (int) get_the_ID();
        }

        if ( $pid !== (int) $ctx['control_id'] ) return $excerpt;

        $shadow_post = get_post( (int) $ctx['shadow_id'] );
        if ( $shadow_post && isset( $shadow_post->post_excerpt ) && $shadow_post->post_excerpt !== '' ) {
            return $shadow_post->post_excerpt;
        }

        return $excerpt;
    }
}

if ( ! function_exists( 'abtestkit_pt_shadow_woocommerce_short_description' ) ) {
    function abtestkit_pt_shadow_woocommerce_short_description( $desc ) {
        if ( is_admin() ) return $desc;
        if ( ! function_exists( 'is_product' ) || ! is_product() ) return $desc;

        $ctx = abtestkit_pt_shadow_ctx_get();
        if ( empty( $ctx['shadow_id'] ) ) return $desc;

        $shadow_post = get_post( (int) $ctx['shadow_id'] );
        if ( $shadow_post && isset( $shadow_post->post_excerpt ) && $shadow_post->post_excerpt !== '' ) {
            return $shadow_post->post_excerpt;
        }

        return $desc;
    }
}

if ( ! function_exists( 'abtestkit_pt_shadow_maybe_activate' ) ) {
    function abtestkit_pt_shadow_maybe_activate() {
        static $did = false;
        if ( $did ) return;

        if ( is_admin() || is_feed() || is_embed() ) return;
        if ( ! function_exists( 'is_product' ) || ! is_product() ) return;

        $control_id = (int) get_queried_object_id();
        if ( $control_id <= 0 ) return;

        // Decide variant/test (supports preview force flags too).
        [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $control_id );
        if ( ! is_array( $test ) || ( $test['kind'] ?? '' ) !== 'product' || $variant !== 'B' ) return;

        // Do NOT depend on wc_get_product() for draft shadows; use the stored ID directly.
        $shadow_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
        if ( $shadow_id <= 0 || $shadow_id === $control_id ) return;

        abtestkit_pt_shadow_ctx_set( $control_id, $shadow_id, (array) $test );

        // Register once.
        $did = true;

        // Shadow meta (Elementor/ACF/builder data mostly lives in post meta).
        add_filter( 'get_post_metadata', 'abtestkit_pt_shadow_get_post_metadata', 9999, 4 );

        // ACF: make get_field()/Elementor ACF integrations resolve against the shadow product.
        add_filter( 'acf/pre_load_post_id', 'abtestkit_pt_shadow_acf_pre_load_post_id', 9999, 2 );

        // Shadow WP fields used by Woo templates.
        add_filter( 'the_title', 'abtestkit_pt_shadow_the_title', 9999, 2 );
        add_filter( 'get_the_excerpt', 'abtestkit_pt_shadow_get_the_excerpt', 9999, 2 );
        add_filter( 'woocommerce_short_description', 'abtestkit_pt_shadow_woocommerce_short_description', 1 );

        // Shadow main product content/tab content (when it *is* used).
        add_filter( 'the_content', 'abtestkit_pt_shadow_the_content', 9999 );
    }
}

add_action( 'wp', 'abtestkit_pt_shadow_maybe_activate', 1 );

/**
 * Optional: Elementor per-post CSS file for the shadow.
 */
add_action( 'wp_enqueue_scripts', function() {
    if ( ! function_exists( 'is_product' ) || ! is_product() ) return;

    $ctx = abtestkit_pt_shadow_ctx_get();
    if ( empty( $ctx['shadow_id'] ) ) return;

    $shadow_id = (int) $ctx['shadow_id'];
    if ( $shadow_id <= 0 ) return;

    $uploads = wp_upload_dir();
    if ( empty( $uploads['basedir'] ) || empty( $uploads['baseurl'] ) ) return;

    $rel  = '/elementor/css/post-' . $shadow_id . '.css';
    $path = rtrim( (string) $uploads['basedir'], '/' ) . $rel;
    $url  = rtrim( (string) $uploads['baseurl'], '/' ) . $rel;

    if ( file_exists( $path ) ) {
        $ver = (string) @filemtime( $path );
        wp_enqueue_style( 'abtestkit-pt-elementor-shadow-' . $shadow_id, $url, [], $ver );
    }
}, 100 );

        /**
     * When visitor is Variant B on a product test, attach shadow product ID to the cart item.
     * Product ID remains the REAL product (A), so SKU/stock/orders stay correct.
     */

        add_filter( 'woocommerce_add_cart_item_data', function( $cart_item_data, $product_id, $variation_id ) {
            [ $test, $variant ] = abtestkit_get_active_product_test_for_product( (int) $product_id );
            if ( ! $test || ! in_array( $variant, [ 'A', 'B' ], true ) ) {
                return $cart_item_data;
            }

            $cart_item_data['abtestkit_pt_id']      = (string) $test['id'];
            $cart_item_data['abtestkit_pt_variant'] = ( $variant === 'B' ? 'B' : 'A' );

            if ( $variant === 'B' ) {
                $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
                if ( $shadow ) {
                    $cart_item_data['abtestkit_shadow_product_id'] = (int) $shadow->get_id();
                }
            }

            return $cart_item_data;
        }, 10, 3 );

add_filter( 'woocommerce_product_get_price', function( $price, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $price;
    }

    static $in = false;
    if ( $in ) return $price;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $price;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $price;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $price;

    $p = $shadow->get_price( 'edit' );
    return ( $p !== '' && $p !== null ) ? $p : $price;

}, 10, 2 );

add_filter( 'woocommerce_product_get_regular_price', function( $price, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $price;
    }

    static $in = false;
    if ( $in ) return $price;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $price;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $price;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $price;

    $p = $shadow->get_regular_price( 'edit' );
    return ( $p !== '' && $p !== null ) ? $p : $price;

}, 10, 2 );

add_filter( 'woocommerce_product_get_sale_price', function( $price, $product ) {

    $pid = ( $product instanceof WC_Product ) ? (int) $product->get_id() : 0;

    if ( $pid > 0 && function_exists( 'abtestkit_is_shadow_product' ) && abtestkit_is_shadow_product( $pid ) ) {
        return $price;
    }

    static $in = false;
    if ( $in ) return $price;
    $in = true;
    [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
    $in = false;

    if ( ! $test || $variant !== 'B' ) return $price;

    $shadow = abtestkit_pt_get_shadow_product_for_test( $test );
    if ( ! $shadow ) return $price;

    if ( $pid > 0 && (int) $shadow->get_id() === $pid ) return $price;

    $p = $shadow->get_sale_price( 'edit' );
    return ( $p !== '' && $p !== null ) ? $p : $price;

}, 10, 2 );

    /**
     * Ensure cart/checkout totals match Version B price (if B differs).
     */
    add_action( 'woocommerce_before_calculate_totals', function( $cart ) {
        if ( is_admin() && ! defined( 'DOING_AJAX' ) ) return;
        if ( ! $cart || ! method_exists( $cart, 'get_cart' ) ) return;

        foreach ( $cart->get_cart() as $key => $item ) {
            if ( empty( $item['abtestkit_shadow_product_id'] ) ) continue;

            $shadow_id = (int) $item['abtestkit_shadow_product_id'];
            if ( $shadow_id <= 0 || ! function_exists( 'wc_get_product' ) ) continue;

            $shadow = wc_get_product( $shadow_id );
            if ( ! ( $shadow instanceof WC_Product ) ) continue;

            // Use the shadow product's current price (raw; no recursive filters)
            $b_price = $shadow->get_price( 'edit' );
            if ( $b_price === '' ) continue;

            if ( isset( $item['data'] ) && $item['data'] instanceof WC_Product ) {
                $item['data']->set_price( (float) $b_price );
            }
        }
    }, 20 );

    /**
     * Display B title + thumbnail in cart/checkout.
     */
    add_filter( 'woocommerce_cart_item_name', function( $name, $cart_item, $cart_item_key ) {
        if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) return $name;

        $shadow_id = (int) $cart_item['abtestkit_shadow_product_id'];
        $t = get_the_title( $shadow_id );
        return $t ? $t : $name;
    }, 10, 3 );

    add_filter( 'woocommerce_cart_item_thumbnail', function( $thumb, $cart_item, $cart_item_key ) {
        if ( empty( $cart_item['abtestkit_shadow_product_id'] ) ) return $thumb;

        $shadow_id = (int) $cart_item['abtestkit_shadow_product_id'];
        if ( ! function_exists( 'wc_get_product' ) ) return $thumb;

        $shadow = wc_get_product( $shadow_id );
        if ( ! ( $shadow instanceof WC_Product ) ) return $thumb;

        $iid = (int) $shadow->get_image_id( 'edit' );
        if ( $iid <= 0 ) return $thumb;

        return wp_get_attachment_image( $iid, 'woocommerce_thumbnail' );
    }, 10, 3 );

    /**
     * Persist variant info into the order line item so orders can be attributed back to A/B.
     */
    add_action( 'woocommerce_checkout_create_order_line_item', function( $item, $cart_item_key, $values, $order ) {
        if ( ! empty( $values['abtestkit_shadow_product_id'] ) ) {
            $item->add_meta_data( '_abtestkit_shadow_product_id', (int) $values['abtestkit_shadow_product_id'], true );
        }

        if ( ! empty( $values['abtestkit_pt_id'] ) ) {
            $item->add_meta_data( '_abtestkit_pt_id', (string) $values['abtestkit_pt_id'], true );
        }

        if ( ! empty( $values['abtestkit_pt_variant'] ) && in_array( $values['abtestkit_pt_variant'], [ 'A', 'B' ], true ) ) {
            $item->add_meta_data( '_abtestkit_pt_variant', (string) $values['abtestkit_pt_variant'], true );
        }
    }, 10, 4 );

    /**
     * Log one purchase event per order per test.
     * Revenue is based on the matched product line total after discounts, excluding shipping.
     */
    function abtestkit_log_wc_purchase_for_order( $order_id ) {
        if ( ! function_exists( 'wc_get_order' ) ) {
            return;
        }

        $order_id = absint( $order_id );
        if ( $order_id <= 0 ) {
            return;
        }

        $order = wc_get_order( $order_id );
        if ( ! $order ) {
            return;
        }

        $grouped = [];

        foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) {
            if ( ! is_object( $item ) || ! method_exists( $item, 'get_meta' ) ) {
                continue;
            }

            $test_id = sanitize_text_field( (string) $item->get_meta( '_abtestkit_pt_id', true ) );
            $variant = sanitize_text_field( (string) $item->get_meta( '_abtestkit_pt_variant', true ) );

            if ( $test_id === '' || ! in_array( $variant, [ 'A', 'B' ], true ) ) {
                continue;
            }

            $test = abtestkit_pt_get( $test_id );
            if ( ! is_array( $test ) ) {
                continue;
            }

            $goal = isset( $test['goal'] ) ? sanitize_key( (string) $test['goal'] ) : '';
            if ( $goal !== 'purchase' ) {
                continue;
            }

            $line_total = 0.0;
            if ( method_exists( $item, 'get_total' ) ) {
                $line_total = (float) $item->get_total();
            }

            if ( ! isset( $grouped[ $test_id ] ) ) {
                $grouped[ $test_id ] = [
                    'variant' => $variant,
                    'amount'  => 0.0,
                    'post_id' => isset( $test['control_id'] ) ? (int) $test['control_id'] : 0,
                ];
            }

            $grouped[ $test_id ]['amount'] += $line_total;
        }

        foreach ( $grouped as $test_id => $row ) {
            $meta_key = '_abtestkit_purchase_logged_' . sanitize_key( (string) $test_id );

            if ( $order->get_meta( $meta_key, true ) ) {
                continue;
            }

            abtestkit_log_event_to_db(
                'purchase',
                (int) $row['post_id'],
                (string) $test_id,
                (string) $row['variant'],
                [
                    'order_id' => $order_id,
                    'amount'   => (float) $row['amount'],
                ]
            );

            $order->update_meta_data( $meta_key, 1 );
        }

        $order->save();
    }

    add_action( 'woocommerce_order_status_processing', 'abtestkit_log_wc_purchase_for_order', 10, 1 );
    add_action( 'woocommerce_order_status_completed', 'abtestkit_log_wc_purchase_for_order', 10, 1 );

    // Show B name on order line items (emails / order received / My Account).
    add_filter( 'woocommerce_order_item_name', function( $name, $item ) {
        if ( ! is_object( $item ) || ! method_exists( $item, 'get_meta' ) ) return $name;

        $shadow_id = (int) $item->get_meta( '_abtestkit_shadow_product_id', true );
        if ( $shadow_id <= 0 ) return $name;

        $t = get_the_title( $shadow_id );
        return $t ? $t : $name;
    }, 10, 2 );

    // --- Extra safety for themes that use the_title() / the_excerpt() directly on products ---

    // Override the_title() for product posts on the frontend so single-product
    // templates and shop loops that use the_title() pick up the B title.
    add_filter( 'the_title', function ( $title, $post_id ) {
        if ( is_admin() ) {
            return $title;
        }

        $post = get_post( $post_id );
        if ( ! $post || $post->post_type !== 'product' ) {
            return $title;
        }

        $product = wc_get_product( $post_id );
        if ( ! $product ) {
            return $title;
        }

        [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
        if ( ! $test || $variant !== 'B' ) {
            return $title;
        }

        $overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
        if ( isset( $overrides['title'] ) && $overrides['title'] !== '' ) {
            return $overrides['title'];
        }

        return $title;
    }, 20, 2 );

    // Override get_the_excerpt() for product posts on the frontend so single-product
    // templates and shop loops that use the_excerpt() pick up the B short description.
    add_filter( 'get_the_excerpt', function ( $excerpt, $post ) {
        if ( is_admin() ) {
            return $excerpt;
        }

        if ( ! $post instanceof WP_Post ) {
            return $excerpt;
        }

        if ( $post->post_type !== 'product' ) {
            return $excerpt;
        }

        if ( ! function_exists( 'wc_get_product' ) ) {
            return $excerpt;
        }

        $product = wc_get_product( $post->ID );
        if ( ! $product ) {
            return $excerpt;
        }

        [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
        if ( ! $test || $variant !== 'B' ) {
            return $excerpt;
        }

        $overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
        if ( isset( $overrides['short_description'] ) && $overrides['short_description'] !== '' ) {
            return abtestkit_render_product_override_html( $overrides['short_description'] );
        }

        return $excerpt;
    }, 20, 2 );

    // Override the main product description content on single-product pages (B only).
    add_filter( 'the_content', function( $content ) {
        if ( is_admin() ) {
            return $content;
        }

        if ( ! function_exists( 'wc_get_product' ) ) {
            return $content;
        }

        global $post;

        if ( ! ( $post instanceof WP_Post ) || $post->post_type !== 'product' ) {
            return $content;
        }

        $product = wc_get_product( $post->ID );
        if ( ! $product ) {
            return $content;
        }

        [ $test, $variant ] = abtestkit_get_active_product_test_for_product( $product );
        if ( ! $test || $variant !== 'B' ) {
            return $content;
        }

        $overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];

        if ( isset( $overrides['description'] ) && $overrides['description'] !== '' ) {
            return abtestkit_render_product_override_html( $overrides['description'] );
        }

        return $content;
    }, 20, 2 );
} );


// Client-side click tracker for Page Tests (Gutenberg buttons)
    add_action( 'wp_footer', function () {
    // Read-only flag to skip click tracker in wizard iframe.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( isset( $_GET['abtestkit_preview'] ) ) { return; }

    if ( ! is_singular( [ 'post', 'page', 'product' ] ) ) return;
    if ( abtestkit_is_exempt_viewer() ) return;

    $post_id = get_the_ID();
    [ $test, $role ] = abtestkit_pt_find_by_post( $post_id );
    if ( ! $test ) return;

    // Derive the variant from the page we are rendering.
    // If we're on the variant page, it's B; if on control, it's A.
    $assigned = ( $role === 'variant' ) ? 'B' : 'A';

    // Optional: defensive fallback to cookie (read-only; sanitize before use).
    if ( $assigned !== 'A' && $assigned !== 'B' ) {
        $cookie_name = 'abtestkit_pt_' . (string) $test['id'];

        $cookie_val_raw = '';
        if ( isset( $_COOKIE[ $cookie_name ] ) ) {
            // Immediately unslash; sanitized below.
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
            $cookie_val_raw = wp_unslash( $_COOKIE[ $cookie_name ] );
        }

        $cookie_val = sanitize_text_field( $cookie_val_raw );

        if ( in_array( $cookie_val, array( 'A', 'B' ), true ) ) {
            $assigned = $cookie_val;
        } else {
            $assigned = 'A';
        }
    }

    $control_id = (int) $test['control_id'];
    $rest  = esc_url_raw( rest_url( 'abtestkit/v1' ) );
    $nonce = wp_create_nonce( 'wp_rest' );

    // Conversion goal for this page test: 'clicks' | 'form' | 'add_to_cart'
    $goal = isset( $test['goal'] ) ? (string) $test['goal'] : '';

    // Targets come from the wizard's "links" field: hrefs or CSS selectors
    $targets = array_values(
        array_filter(
            array_map( 'strval', (array) ( $test['links'] ?? [] ) )
        )
    );

    // If goal is WooCommerce add_to_cart and no explicit targets were set,
    // default to common single-product add to cart selectors.
    if ( $goal === 'add_to_cart' && empty( $targets ) ) {
        $targets = [
            'form.cart',
            '.single_add_to_cart_button',
            'form.cart button[type="submit"]',
            'form.cart input[type="submit"]',
        ];
    }

    ?>
<script>
(function(){
  var cfg = {
    rest: "<?php echo esc_js( $rest ); ?>",
    nonce: "<?php echo esc_js( $nonce ); ?>",
    postId: <?php echo (int) $control_id; ?>,
    abTestId: "<?php echo esc_js( $test['id'] ); ?>",
    variant: "<?php echo ( $assigned === 'B' ? 'B' : 'A' ); ?>",
    goal: "<?php echo esc_js( $goal ); ?>",
    targets: <?php echo wp_json_encode( $targets ); ?> // hrefs or CSS selectors
  };

  if (!Array.isArray(cfg.targets)) cfg.targets = [];

  // --- Helpers --------------------------------------------------------------
  function trackClickOnce() {
    var key = "abtestkit_pt_clicked_" + cfg.abTestId;
    if (sessionStorage.getItem(key) === "1") return;
    sessionStorage.setItem(key, "1");
    fetch(cfg.rest + "/track?t=" + Date.now(), {
      method: "POST",
      credentials: "same-origin",
      keepalive: true,
      cache: "no-store",
      headers: {
        "Content-Type": "application/json",
        "X-WP-Nonce": cfg.nonce
      },
      body: JSON.stringify({
        type: "click",
        abTestId: cfg.abTestId,
        postId: cfg.postId,
        index: 0,
        variant: cfg.variant,
        protocol: (window.location.protocol === "https:" ? "https" : "http")
      })
    }).catch(function(){});
  }

  function isCssSelector(s) {
    // Rough check: starts with ., #, [, or contains spaces/combinators; or starts with 'form'
    return /^[.#\[]/.test(s) || /\s|>|\+|~/.test(s) || /^form\b/i.test(s);
  }

  function normalizeUrl(u) {
    try {
      var a = document.createElement('a');
      a.href = u;
      // compare path + query (ignore origin + hash), trim trailing slash
      var path = a.pathname.replace(/\/+$/,'') || '/';
      var search = a.search || '';
      return (path + search).toLowerCase();
    } catch(e) {
      return (u || '').toLowerCase();
    }
  }

  function hrefMatches(targetHref, elementHref) {
    var t = normalizeUrl(targetHref);
    var e = normalizeUrl(elementHref);
    if (!t || !e) return false;

    // Allow wildcard suffix: "/pricing*" matches "/pricing" and "/pricing?x=1"
    if (/\*$/.test(t)) {
      t = t.slice(0, -1);
      return e.indexOf(t) === 0;
    }
    // Exact path+query match
    return e === t;
  }

  function matchesAnyTarget(el) {
    if (!cfg.targets.length || !el) return false;

    // If it's an <a>, try href matches first
    var anchor = el.closest && el.closest('a');
    if (anchor && anchor.href) {
      for (var i=0; i<cfg.targets.length; i++) {
        var t = cfg.targets[i];
        if (!t) continue;
        if (!isCssSelector(t) && hrefMatches(t, anchor.href)) return true;
      }
    }

    // CSS selectors: match by closest()
    for (var j=0; j<cfg.targets.length; j++) {
      var sel = cfg.targets[j];
      if (!sel) continue;
      if (isCssSelector(sel)) {
        try {
          if (el.closest && el.closest(sel)) return true;
        } catch(e) {
          // invalid selector — ignore
        }
      }
    }
    return false;
  }

  // NEW: For product/page tests where the goal is "add_to_cart",
  //  track conversions centrally in assets/js/frontend.js
  // (PAGE_TEST.goal === "add_to_cart", via Woo's added_to_cart + form submit).
  // To avoid double-counting, do not attach these generic click/submit handlers.
  if (cfg.goal === "add_to_cart") {
    return;
  }

  if (window.location.protocol !== "https:") {
    return;
  }

  // --- Listeners ------------------------------------------------------------
  document.addEventListener("click", function(ev){
    var t = ev.target;
    if (!t || typeof t.closest !== "function") return;

    var clickable = t.closest(
      'a[href],button,[role="button"],[onclick],input[type="submit"],.wp-block-button__link'
    );
    if (!clickable) return;

    if (matchesAnyTarget(clickable)) {
      trackClickOnce();
    }
  }, {passive:true});

  // Forms: if a selector target points to a form, track on submit
  document.addEventListener("submit", function(ev){
    var f = ev.target;
    if (!f || !(f instanceof HTMLFormElement)) return;

    // Only bother if user configured any selector that could match forms
    var anyFormish = cfg.targets.some(function(sel){ return sel && isCssSelector(sel); });
    if (!anyFormish) return;

    if (matchesAnyTarget(f)) {
      trackClickOnce();
    }
  }, {capture:true}); // capture to fire even if page handlers stopPropagation

})();
</script>
<?php
}, 99 );

register_uninstall_hook(__FILE__, 'abtestkit_uninstall');
function abtestkit_uninstall() {
    global $wpdb;

    // --- 1) Drop events table ---
    $table = defined( 'ABTESTKIT_EVENTS_TABLE' ) ? ABTESTKIT_EVENTS_TABLE : ( $wpdb->prefix . 'abtestkit_events' );
    $table_esc = esc_sql( $table );

    // Schema change on a custom table. Identifier is safe (prefix + fixed suffix).
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
    $wpdb->query( 'DROP TABLE IF EXISTS `' . $table_esc . '`' );



    // --- 2) Remove stored per-post meta mirror ---
    if (function_exists('delete_post_meta_by_key')) {
        delete_post_meta_by_key('_abtestkit_variants');
    } else {
        // Maintenance delete for stored meta during uninstall (prepared value).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( $wpdb->prepare(
         "DELETE pm FROM {$wpdb->postmeta} pm WHERE pm.meta_key = %s",
         '_abtestkit_variants'
        ) );
    }

    // --- 3) Strip AB attributes from saved block content (safe & optional) ---
    // This ensures blocks re-render as normal Gutenberg blocks everywhere.
    $post_types = get_post_types(['public' => true], 'names');
    if (!empty($post_types)) {
        // Process in batches to avoid timeouts on moderate sites
        foreach ($post_types as $pt) {
            $paged = 1;
            do {
                $q = new WP_Query([
                    'post_type'      => $pt,
                    'post_status'    => 'any',
                    'posts_per_page' => 200,
                    'paged'          => $paged,
                    'no_found_rows'  => true,
                    'fields'         => 'ids',
                ]);
                if (empty($q->posts)) { break; }

                foreach ($q->posts as $pid) {
                    $content = get_post_field('post_content', $pid);
                    if (!$content) { continue; }

                    $changed = false;
                    $blocks  = parse_blocks($content);
                    $blocks  = abtestkit_strip_ab_attrs_from_blocks($blocks, $changed);

                    if ($changed) {
                        $new_html = serialize_blocks($blocks);
                        // Update post content with AB attributes removed
                        wp_update_post([
                            'ID'           => $pid,
                            'post_content' => $new_html,
                        ]);
                    }

                    // Remove per-post meta mirror just in case
                    delete_post_meta($pid, '_abtestkit_variants');
                }

                $paged++;
            } while (true);
        }
    }

    // --- 4) Purge rate-limit transients ---
    // Maintenance: clear this plugin’s rate-limit transients on deactivation.
    // Maintenance: clear this plugin’s rate-limit transients on uninstall.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_abtestkit_trk_%'" );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_abtestkit_trk_%'" );

    // --- 5) Best-effort cache flush so no stale HTML remains in caches/CDNs ---
    if (function_exists('wp_cache_flush')) { wp_cache_flush(); }
    if (function_exists('w3tc_flush_all')) { w3tc_flush_all(); }
    if (function_exists('rocket_clean_domain')) { rocket_clean_domain(); }
    if (function_exists('autoptimize_flush_cache')) { autoptimize_flush_cache(); }
    // Purge LiteSpeed Cache via documented external hook.
    // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    if (class_exists('LiteSpeed_Cache')) { do_action('litespeed_purge_all'); }
    if (function_exists('wp_cache_clear_cache')) { wp_cache_clear_cache(); }
}

function abtestkit_events_table_has_column( string $column ) : bool {
    global $wpdb;

    static $columns = null;

    if ( ! is_array( $columns ) ) {
        $table = ABTESTKIT_EVENTS_TABLE;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $found = $wpdb->get_col(
            $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ),
            0
        );

        $columns = is_array( $found ) ? $found : [];
    }

    return in_array( $column, $columns, true );
}

function abtestkit_create_event_table() {
    global $wpdb;

    $table           = ABTESTKIT_EVENTS_TABLE;
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE $table (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        time DATETIME NOT NULL,
        post_id BIGINT,
        ab_test_id VARCHAR(64),
        variant CHAR(1),
        event_type ENUM('impression','click','purchase','decision','decision_applied','stale','protocol_warning'),
        order_id BIGINT NULL,
        amount DECIMAL(18,2) NULL,
        protocol VARCHAR(10) NULL,
        excluded_reason VARCHAR(50) NULL,
        ip VARCHAR(45),
        user_agent TEXT,
        KEY idx_time (time),
        KEY idx_post_test (post_id, ab_test_id),
        KEY idx_variant_type (variant, event_type),
        KEY idx_order_id (order_id),
        KEY idx_protocol_reason (protocol, excluded_reason),
        KEY idx_test_type_variant_time (ab_test_id, event_type, variant, time),
        KEY idx_test_time (ab_test_id, time),
        KEY idx_test_variant (ab_test_id, variant)
    ) $charset_collate;";

    require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );

    // phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- Required schema creation/upgrades for plugin-owned custom table.
    dbDelta( $sql );

    // Force-heal older installs where dbDelta/version flags do not fully catch up.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $columns = $wpdb->get_col(
        $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ),
        0
    );

    if ( is_array( $columns ) ) {
        if ( ! in_array( 'order_id', $columns, true ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query(
                $wpdb->prepare(
                    'ALTER TABLE %i ADD COLUMN `order_id` BIGINT NULL AFTER `event_type`',
                    $table
                )
            );
        }

        if ( ! in_array( 'amount', $columns, true ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query(
                $wpdb->prepare(
                    'ALTER TABLE %i ADD COLUMN `amount` DECIMAL(18,2) NULL AFTER `order_id`',
                    $table
                )
            );
        }

        if ( ! in_array( 'protocol', $columns, true ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query(
                $wpdb->prepare(
                    'ALTER TABLE %i ADD COLUMN `protocol` VARCHAR(10) NULL AFTER `amount`',
                    $table
                )
            );
        }

        if ( ! in_array( 'excluded_reason', $columns, true ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query(
                $wpdb->prepare(
                    'ALTER TABLE %i ADD COLUMN `excluded_reason` VARCHAR(50) NULL AFTER `protocol`',
                    $table
                )
            );
        }
    }

    // Add missing indexes if needed.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $order_index = $wpdb->get_var(
        $wpdb->prepare(
            'SHOW INDEX FROM %i WHERE Key_name = %s',
            $table,
            'idx_order_id'
        )
    );

    if ( ! $order_index ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query(
            $wpdb->prepare(
                'ALTER TABLE %i ADD KEY `idx_order_id` (`order_id`)',
                $table
            )
        );
    }

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $protocol_index = $wpdb->get_var(
        $wpdb->prepare(
            'SHOW INDEX FROM %i WHERE Key_name = %s',
            $table,
            'idx_protocol_reason'
        )
    );

    if ( ! $protocol_index ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query(
            $wpdb->prepare(
                'ALTER TABLE %i ADD KEY `idx_protocol_reason` (`protocol`, `excluded_reason`)',
                $table
            )
        );
    }

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $test_type_variant_time_index = $wpdb->get_var(
        $wpdb->prepare(
            'SHOW INDEX FROM %i WHERE Key_name = %s',
            $table,
            'idx_test_type_variant_time'
        )
    );

    if ( ! $test_type_variant_time_index ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query(
            $wpdb->prepare(
                'ALTER TABLE %i ADD KEY `idx_test_type_variant_time` (`ab_test_id`, `event_type`, `variant`, `time`)',
                $table
            )
        );
    }

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $test_time_index = $wpdb->get_var(
        $wpdb->prepare(
            'SHOW INDEX FROM %i WHERE Key_name = %s',
            $table,
            'idx_test_time'
        )
    );

    if ( ! $test_time_index ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query(
            $wpdb->prepare(
                'ALTER TABLE %i ADD KEY `idx_test_time` (`ab_test_id`, `time`)',
                $table
            )
        );
    }

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $test_variant_index = $wpdb->get_var(
        $wpdb->prepare(
            'SHOW INDEX FROM %i WHERE Key_name = %s',
            $table,
            'idx_test_variant'
        )
    );

    if ( ! $test_variant_index ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query(
            $wpdb->prepare(
                'ALTER TABLE %i ADD KEY `idx_test_variant` (`ab_test_id`, `variant`)',
                $table
            )
        );
    }
    // phpcs:enable WordPress.DB.DirectDatabaseQuery.SchemaChange

    update_option( 'abtestkit_events_schema_version', 4, false );
}

add_action( 'admin_init', function() {
    // Always run the schema healer on admin requests.
    // It is cheap, and it fixes older live sites where the version flag says "2"
    // but the table is still missing newer columns like order_id / amount.
    abtestkit_create_event_table();
} );

// Align abtestkit admin menu icon
add_action( 'admin_head', function () {
    ?>
    <style>
        #adminmenu #toplevel_page_abtestkit-dashboard .wp-menu-image img {
            width: 20px !important;
            height: 20px !important;
            padding-top: 5px;
        }
    </style>
    <?php
});
