<?php
/**
 * Service container and bootstrapper for TitanCart.
 *
 * @package TitanCart\Core
 */

namespace TitanCart\Core;

defined( 'ABSPATH' ) || exit;

use TitanCart\DB\Database;
use TitanCart\Admin\AdminController;
use TitanCart\Admin\Assets;
use TitanCart\Modules\Products\ProductsModule;
use TitanCart\Storefront\StorefrontModule;
use TitanCart\Modules\Cart\CartModule;
use TitanCart\Modules\Checkout\CheckoutModule;
use TitanCart\Modules\Orders\OrdersModule;
use TitanCart\Modules\Payments\PaymentsModule;
use TitanCart\Modules\Shipping\ShippingModule;
use TitanCart\Modules\Tax\TaxModule;
use TitanCart\Modules\Accounts\AccountsModule;
use TitanCart\Modules\Wishlist\WishlistModule;
use TitanCart\Modules\Reviews\ReviewsModule;
use TitanCart\Modules\Reports\ReportsModule;
use TitanCart\Modules\ImportExport\ImportExportModule;
use TitanCart\Modules\Settings\SettingsModule;
use TitanCart\Modules\Todo\TodoModule;
use TitanCart\Modules\Calendar\CalendarModule;
use TitanCart\Modules\Notifications\NotificationsModule;
use TitanCart\Modules\Comments\CommentsModule;
use TitanCart\Modules\Faq\FaqModule;
use TitanCart\Modules\WooImport\WooImportModule;
use TitanCart\Modules\Downloadables\DownloadableModule;
use TitanCart\Modules\Pages\PagesModule;
use TitanCart\REST\Seo\SeoController;
use TitanCart\Services\SeoService;
use TitanCart\Services\SettingsService;
use TitanCart\Services\EmailTransport;
use TitanCart\REST\Extensions\ExtensionsController;
use TitanCart\REST\License\LicenseController;
use TitanCart\Extensions\SitemapExtension;
use TitanCart\Extensions\BestsellersExtension;
use TitanCart\Extensions\GdprExtension;
use TitanCart\Extensions\RecaptchaExtension;
use TitanCart\Extensions\AgeVerificationExtension;
use TitanCart\Core\UpdateManager;
use TitanCart\Core\LicenseManager;
use TitanCart\Core\RestHardening;

class Plugin {

    /* ── KI-64 — license-inactive notice snooze ──────────────────────────────
     * The notice is the ONLY signal an unentitled store gets, so its dismissal
     * is a bounded, per-episode snooze rather than a permanent hide. See
     * license_inactive_notice() / ajax_dismiss_license_notice(). */
    private const LICENSE_NOTICE_SNOOZE_DAYS   = 7;
    private const LICENSE_NOTICE_SNOOZE_META   = 'titancart_license_notice_snooze';
    private const LICENSE_NOTICE_EPISODE_OPTION = 'titancart_license_inactive_episode';
    private const LICENSE_NOTICE_NONCE          = 'titancart_dismiss_license_notice';

    private static ?Plugin $instance = null;
    private array $services = [];

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

    private function __construct() {}

    /**
     * Boot the plugin — called on plugins_loaded (priority 0).
     */
    public function boot(): void {
        // Core services.
        $this->register( 'db',    new Database() );
        $this->register( 'hooks', new HookManager() );

        // Register PHP error/exception handlers for System Logs.
        LogService::register_error_handlers();

        // ── Security hardening (CORE — runs on Lite too, no Edition gate) ──────
        // Closes the two surfaces abused in the 2026-08-04 titancartdemo.com
        // incident: the REST batch endpoint (brute-force amplifier) and
        // unthrottled wp-login. Registered here, before modules and before any
        // Edition/entitlement branch, so the `authenticate`/`rest_endpoints`
        // filters are in place for the earliest possible request. Fully
        // filterable (titancart_security_*); see Core\RestHardening.
        RestHardening::init();

        // Auto-run pending column migrations (ENUM changes etc.) that dbDelta can't handle.
        self::maybe_run_migrations();

        // Admin.
        if ( is_admin() ) {
            $this->register( 'admin',  new AdminController() );
            $this->register( 'assets', new Assets() );
        }

        // Translations: none are bundled. WordPress ≥4.6 auto-loads .org
        // language packs just-in-time, so no load_plugin_textdomain() call is
        // needed (Plugin Check flags it as discouraged). If in-package
        // translations ever ship under /languages, re-add the loader here.

        // ── Module bootstrappers ──────────────────────────────────────────────
        // Modules are initialised directly here before firing 'titancart_loaded',
        // so that their hooks/routes are registered before external code runs.
        ProductsModule::init();    // Module 2 — Products + Categories
        StorefrontModule::init();  // Storefront routing, templates, assets
        CartModule::init();        // Module 3 — Cart Engine
        CheckoutModule::init();    // Module 4 — Checkout + Order Placement
        OrdersModule::init();      // Module 5 — Orders + Email
        PaymentsModule::init();    // Module 6 — Payments
        ShippingModule::init();    // Module 7 — Shipping
        TaxModule::init();         // Module 8 — Tax
        AccountsModule::init();    // Module 10 — Customer Accounts
        WishlistModule::init();    // Module 10 — Wishlists
        ReviewsModule::init();     // Module 11 — Product Reviews
        ReportsModule::init();     // Module 12 — Reports & Analytics
        ImportExportModule::init(); // Module 13 — Import/Export
        SettingsModule::init();     // Settings — admin settings management
        TodoModule::init();         // Applications — To Do list
        CalendarModule::init();     // Applications — Calendar
        NotificationsModule::init(); // Admin notification bell (KI-26) — order + vendor-application events
        CommentsModule::init();     // Comments & Discussions — testimonials, page comments, category reviews
        FaqModule::init();          // FAQ page manager + [titancart_faq] accordion (S478)
        WooImportModule::init();    // WooCommerce Migration Tool
        DownloadableModule::init(); // Downloadable Products — keys, agreements, file delivery
        PagesModule::init();        // Page Manager (roadmap #2) — REST surface over PageRegistry

        // ── PHONE-HOME BLOCK ──────────────────────────────────────────────────
        // Everything in this block makes outbound HTTP calls to
        // market.titancart.io. The WP.org "TitanCart Lite" package pins
        // TITANCART_NO_PHONEHOME=true AND physically omits these four classes
        // (build/manifest.php strips them), because the .org repo forbids
        // fetching or executing remote code.
        //
        // Two independent guards, both required:
        //   - ! TITANCART_NO_PHONEHOME  → intent. Lets a dev force the posture on any
        //                          install without deleting files.
        //   - class_exists(...)  → survival. The Lite zip has no file to
        //                          autoload; a bare static call would fatal.
        //
        // class_exists() triggers the autoloader, which simply misses and
        // returns false. The `use` aliases at the top of this file are lazy and
        // safe to leave in place.
        // NOTE: guarded with defined() — PHP 8 removed the bareword-constant
        // fallback, so a bare `! TITANCART_NO_PHONEHOME` would be a fatal Error if this
        // class is ever loaded without the bootstrap (unit tests, tooling).
        if ( ! ( defined( 'TITANCART_NO_PHONEHOME' ) && TITANCART_NO_PHONEHOME ) ) {

            // Marketplace extension update checker — detects updates from market.titancart.io.
            if ( class_exists( UpdateManager::class ) ) {
                UpdateManager::init();
            }

            // S355: the Pro entitlement catalog is cached for 6h. A license
            // change (activate, deactivate, renew, lapse) must invalidate it
            // immediately, or a store that just subscribed keeps seeing an
            // empty catalog for up to six hours. Excluded from Lite, hence the
            // class_exists() guard — the same contract as UpdateManager above.
            if ( class_exists( CatalogClient::class ) ) {
                add_action( 'titancart_license_changed', [ CatalogClient::class, 'clear_cache' ] );
            }

            // License manager — activate/deactivate/check paid extensions against
            // the marketplace. Self-schedules a daily heartbeat to catch expired
            // or revoked licenses within 24h of the change.
            if ( class_exists( LicenseManager::class ) ) {
                LicenseManager::init();
            }

            // Host plugin license REST routes — powers the Account & License
            // admin SPA page. Uses LicenseManager under the hood with slug
            // 'titancart' so the host plugin shares the same activation /
            // heartbeat / fail-open machinery as paid extensions.
            if ( class_exists( LicenseController::class ) ) {
                add_action( 'rest_api_init', function () {
                    ( new LicenseController() )->register_routes();
                } );
            }
        }
        // ── END PHONE-HOME BLOCK ──────────────────────────────────────────────


        // Admin notice — license-inactive alert. Pure HTML output via the
        // admin_notices hook so it appears wherever the admin is, without
        // requiring the SPA bundle to be loaded. (The beta-mode banner was
        // removed with the rest of the beta scaffolding — KI-41, S416.)
        add_action( 'admin_notices', [ self::class, 'license_inactive_notice' ] );

        // KI-64 — persisted dismissal for the license-inactive notice. WP's
        // `is-dismissible` posts nothing, so the × was cosmetic and the notice
        // returned on every admin page forever. This is deliberately admin-ajax
        // rather than a TitanCart REST route: dismissing a wp-admin notice is
        // thin WordPress admin chrome, not commerce logic, so it does not belong
        // in the portable engine's own API surface (Rule 4).
        add_action( 'wp_ajax_titancart_dismiss_license_notice', [ self::class, 'ajax_dismiss_license_notice' ] );

        // SEO — settings, redirects, robots.txt
        add_action( 'rest_api_init', function() { ( new SeoController() )->register_routes(); } );

        // SSL — redirect HTTP → HTTPS on storefront when setting enabled.
        add_action( 'template_redirect', function() {
            if ( is_admin() || is_ssl() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
                return;
            }
            if ( SettingsService::bool( 'security', 'ssl_storefront', true ) ) {
                $tc_ssl_host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) );    // WP.org R2 item 5: unslash+sanitize at boundary.
                $tc_ssl_uri  = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) ); // wp_safe_redirect's allowed-host check remains the redirect gate.
                wp_safe_redirect( 'https://' . $tc_ssl_host . $tc_ssl_uri, 301 );
                exit;
            }
        }, 1 ); // Priority 1 — run before other template_redirect hooks.

        // SSL — redirect HTTP → HTTPS on admin pages when setting enabled.
        add_action( 'admin_init', function() {
            if ( is_ssl() || ( defined( 'DOING_CRON' ) && DOING_CRON ) || wp_doing_ajax() ) {
                return;
            }
            if ( SettingsService::bool( 'security', 'ssl_admin', true ) ) {
                $tc_ssl_host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) );    // WP.org R2 item 5: unslash+sanitize at boundary.
                $tc_ssl_uri  = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) ); // wp_safe_redirect's allowed-host check remains the redirect gate.
                wp_safe_redirect( 'https://' . $tc_ssl_host . $tc_ssl_uri, 301 );
                exit;
            }
        }, 1 );

        // Security headers — emit the safe, content-independent response headers
        // (nosniff / X-Frame-Options / Referrer-Policy) when enabled in
        // Settings → Security → Security Headers. Opt-in, scoped to
        // storefront / admin / both, and duplicate-guarded so it never overrides
        // a header the host or another plugin already sets. Roadmap #12 (the
        // CSP-friendly output audit is a separate, larger follow-up). The
        // storefront uses send_headers; the admin uses admin_init because the
        // front-end send_headers hook does not fire on wp-admin requests.
        add_action( 'send_headers', function() {
            if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
                return;
            }
            self::emit_security_headers( 'storefront' );
        } );
        add_action( 'admin_init', function() {
            if ( wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
                return;
            }
            self::emit_security_headers( 'admin' );
        } );

        // Password expiry — check on login whether password must be changed.
        add_action( 'wp_login', function( $user_login, $user ) {
            $user_id = $user->ID;
            $needs_change = false;

            // 1. force_admin_password_change — flag set when admin creates the user.
            if ( get_user_meta( $user_id, 'tc_must_change_password', true ) ) {
                $needs_change = true;
            }

            // 2. password_validity_days — password has expired.
            $validity = SettingsService::int( 'security', 'password_validity_days', 0 );
            if ( $validity > 0 ) {
                $changed_at = (int) get_user_meta( $user_id, 'tc_password_changed_at', true );
                if ( $changed_at > 0 && ( time() - $changed_at ) > ( $validity * DAY_IN_SECONDS ) ) {
                    $needs_change = true;
                }
            }

            if ( $needs_change ) {
                update_user_meta( $user_id, 'tc_password_expired', '1' );
            } else {
                delete_user_meta( $user_id, 'tc_password_expired' );
            }
        }, 10, 2 );

        // Password expiry — redirect to profile page if flag is set (storefront only).
        add_action( 'template_redirect', function() {
            if ( ! is_user_logged_in() ) return;

            $expired = get_user_meta( get_current_user_id(), 'tc_password_expired', true );
            if ( $expired !== '1' ) return;

            // Don't redirect if already on profile page or logout.
            $uri = trim( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ), '/' );
            if ( strpos( $uri, 'my-account/profile' ) !== false || strpos( $uri, 'wp-login.php?action=logout' ) !== false ) {
                return;
            }

            wp_safe_redirect( home_url( '/my-account/profile/?password_expired=1' ) );
            exit;
        }, 2 );

        // Password expiry — same enforcement for wp-admin (admin-class users typically
        // log into wp-admin, where template_redirect never fires). We bounce them to
        // wp-admin/profile.php which has the native WP password-change form.
        add_action( 'admin_init', function() {
            if ( ! is_user_logged_in() ) return;
            if ( wp_doing_ajax() ) return;

            $expired = get_user_meta( get_current_user_id(), 'tc_password_expired', true );
            if ( $expired !== '1' ) return;

            global $pagenow;
            // Allow the profile page (where they change password) and request-handling
            // endpoints (admin-ajax, admin-post) so password-update POSTs succeed.
            $allow = [ 'profile.php', 'admin-ajax.php', 'admin-post.php' ];
            if ( in_array( $pagenow, $allow, true ) ) return;

            wp_safe_redirect( admin_url( 'profile.php?tc_password_expired=1' ) );
            exit;
        }, 2 );

        // Password expiry — render an explanatory banner on wp-admin/profile.php so
        // the bounced admin understands why they're locked here. Native WP profile.php
        // has no awareness of TitanCart, so without this notice the situation is opaque.
        add_action( 'admin_notices', function() {
            global $pagenow;
            if ( $pagenow !== 'profile.php' ) return;
            if ( ! is_user_logged_in() ) return;

            $expired = get_user_meta( get_current_user_id(), 'tc_password_expired', true );
            if ( $expired !== '1' ) return;

            ?>
            <div class="notice notice-error" style="border-left-width:6px;padding:14px 18px;">
                <p style="font-size:14px;font-weight:600;margin:0 0 6px 0;">
                    <?php esc_html_e( 'Password change required', 'titancart-lite' ); ?>
                </p>
                <p style="margin:0;">
                    <?php esc_html_e( 'You must change your password before you can access the rest of the admin area. Scroll down to the "Account Management" section, click "Set New Password", enter a new password, and click "Update Profile" at the bottom of the page.', 'titancart-lite' ); ?>
                </p>
            </div>
            <?php
        } );

        // Password expiry — clear flags when a wp-admin user changes their password
        // via wp-admin/profile.php (uses WordPress's native password update flow,
        // not TitanCart's AccountManager which already clears these).
        add_action( 'profile_update', function( $user_id, $old_user_data ) {
            $new_user = get_userdata( $user_id );
            if ( ! $new_user || ! $old_user_data ) return;
            if ( $new_user->user_pass === $old_user_data->user_pass ) return;

            update_user_meta( $user_id, 'tc_password_changed_at', time() );
            delete_user_meta( $user_id, 'tc_must_change_password' );
            delete_user_meta( $user_id, 'tc_password_expired' );
        }, 10, 2 );

        // Password expiry — clear flags after lost-password reset flow completes.
        add_action( 'after_password_reset', function( $user ) {
            if ( ! $user || empty( $user->ID ) ) return;
            update_user_meta( $user->ID, 'tc_password_changed_at', time() );
            delete_user_meta( $user->ID, 'tc_must_change_password' );
            delete_user_meta( $user->ID, 'tc_password_expired' );
        }, 10, 1 );

        // SEO — handle 301/302 redirects on the frontend (only on 404 pages for performance).
        add_action( 'template_redirect', function() {
            if ( ! is_404() ) {
                return;
            }

            $request_uri = trim( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ), '/' );
            if ( $request_uri === '' ) {
                return;
            }

            // Normalize: ensure leading slash, strip query string.
            $path = '/' . strtok( $request_uri, '?' );
            // Also try with trailing slash.
            $path_slash   = rtrim( $path, '/' ) . '/';
            $path_noslash = rtrim( $path, '/' );

            try {
                $db    = titancart_db();
                $table = $db->table( 'seo_redirects' );

                $redirect = $db->getRow(
                    "SELECT to_url, type FROM {$table}
                     WHERE status = 'active'
                       AND (from_url = ?s OR from_url = ?s OR from_url = ?s)
                     LIMIT 1",
                    $path, $path_slash, $path_noslash
                );

                if ( $redirect && ! empty( $redirect['to_url'] ) ) {
                    $to   = home_url( $redirect['to_url'] );
                    $code = (int) ( $redirect['type'] ?? 301 );
                    if ( ! in_array( $code, [ 301, 302 ], true ) ) {
                        $code = 301;
                    }

                    // Increment hit counter.
                    $db->query(
                        "UPDATE {$table} SET hits = hits + 1 WHERE from_url = ?s OR from_url = ?s OR from_url = ?s",
                        $path, $path_slash, $path_noslash
                    );

                    wp_safe_redirect( $to, $code );
                    exit;
                }
            } catch ( \Throwable $e ) {
                // Silently fail if table doesn't exist yet.
            }
        } );

        // ── Currency filter (S120) — primary symbol from tc_currencies ──
        // Existing templates call apply_filters('titancart_currency_symbol', '$').
        // Registering CurrencyService::primary_symbol() here back-fills the right
        // value for every consumer with zero template surgery.
        add_filter( 'titancart_currency_symbol', [ \TitanCart\Services\CurrencyService::class, 'primary_symbol' ] );

        // SEO URL filters — override dynamic shop/product/category URLs.
        add_filter( 'titancart_shop_url', function() { return SeoService::get_shop_url(); } );
        add_filter( 'titancart_product_url', function( $url, $product_id ) {
            $db   = titancart_db();
            $slug = $db->getField( "SELECT slug FROM {$db->table('products')} WHERE id = ?i", $product_id );
            return $slug ? SeoService::product_url( $slug ) : $url;
        }, 10, 2 );

        // ── Image quality (S128 #9) — JPEG compression for all thumbnail generation ──
        // Reads thumbnails.jpeg_quality (0-100) and applies to wp_get_image_editor's
        // ::set_quality() default. Affects ImageService::generate_thumbnails AND any
        // other code path that uses WP's image editor pipeline.
        add_filter( 'jpeg_quality', function( $quality ) {
            $configured = \TitanCart\Services\SettingsService::int( 'thumbnails', 'jpeg_quality', 0 );
            return ( $configured > 0 && $configured <= 100 ) ? $configured : $quality;
        } );
        add_filter( 'wp_editor_set_quality', function( $quality, $mime_type ) {
            // Only adjust JPEG quality; other formats keep WP defaults.
            if ( $mime_type !== 'image/jpeg' && $mime_type !== 'image/jpg' ) {
                return $quality;
            }
            $configured = \TitanCart\Services\SettingsService::int( 'thumbnails', 'jpeg_quality', 0 );
            return ( $configured > 0 && $configured <= 100 ) ? $configured : $quality;
        }, 10, 2 );

        // ── Email transport (SMTP / Sendmail config from Settings → E-mails) ─
        EmailTransport::init();

        // ── Extensions (gated by is_active check internally) ─────────────
        SitemapExtension::init();      // Google Sitemap XML
        BestsellersExtension::init();  // Sales tracking + bestseller queries
        GdprExtension::init();         // Cookie consent banner
        RecaptchaExtension::init();    // Google reCAPTCHA bot protection (login/register/checkout/review)
        AgeVerificationExtension::init(); // Age-restricted product gate (server-side cart/checkout enforcement)

        // ── GDPR deferred-erasure cron (S246) ─────────────────────────────
        // Registered UNCONDITIONALLY — independent of the GDPR toggle. Once a
        // deletion request schedules an order-PII erasure (tc_customers.
        // erasure_scheduled_at), that hold is a legal commitment that must
        // still complete even if the GDPR extension is later disabled.
        if ( ! wp_next_scheduled( 'titancart_gdpr_erasure_tick' ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'titancart_gdpr_erasure_tick' );
        }
        add_action( 'titancart_gdpr_erasure_tick', [ GdprExtension::class, 'run_scheduled_erasures' ] );



        /**
         * Fires after all built-in TitanCart modules have been initialised.
         * External add-ons should hook here to register their own functionality.
         */
        do_action( 'titancart_loaded' );

        // ── Admin bar — add TitanCart links for admins ───────────────────────
        add_action( 'admin_bar_menu', [ self::class, 'admin_bar_links' ], 90 );
    }

    public function register( string $id, object $service ): void {
        $this->services[ $id ] = $service;
    }

    public function get( string $id ): object {
        if ( ! isset( $this->services[ $id ] ) ) {
            throw new \RuntimeException( esc_html( "TitanCart: Service '{$id}' not found." ) );
        }
        return $this->services[ $id ];
    }

    public static function service( string $id ): object {
        return self::instance()->get( $id );
    }

    /**
     * Emit safe, content-independent security headers (roadmap #12).
     *
     * Sends the recommended hardening trio — X-Content-Type-Options,
     * X-Frame-Options, Referrer-Policy — when the merchant has opted in via
     * Settings → Security → Security Headers. Each header has its own toggle,
     * and the whole feature is gated by a master switch (default OFF) plus a
     * scope select (storefront / admin / both).
     *
     * Duplicate-guarded: if the response already carries one of these headers
     * (because the host, a server config, or another plugin set it) TitanCart
     * leaves the existing value in place rather than overriding it, so the
     * toggle is safe to enable on any hosting setup.
     *
     * @param string $context Either 'storefront' or 'admin' — the surface this
     *                        call fires for, matched against the configured scope.
     * @return void
     */
    private static function emit_security_headers( string $context ): void {
        if ( headers_sent() ) {
            return;
        }
        if ( ! SettingsService::bool( 'security', 'security_headers_enabled', false ) ) {
            return;
        }

        $scope = SettingsService::get( 'security', 'security_headers_scope', 'both' );
        if ( $scope !== 'both' && $scope !== $context ) {
            return;
        }

        // Build the desired set from the per-header toggles.
        $desired = [];
        if ( SettingsService::bool( 'security', 'security_header_nosniff', true ) ) {
            $desired['X-Content-Type-Options'] = 'nosniff';
        }
        if ( SettingsService::bool( 'security', 'security_header_frame', true ) ) {
            $desired['X-Frame-Options'] = 'SAMEORIGIN';
        }
        if ( SettingsService::bool( 'security', 'security_header_referrer', true ) ) {
            $desired['Referrer-Policy'] = 'strict-origin-when-cross-origin';
        }
        if ( empty( $desired ) ) {
            return;
        }

        // Duplicate-header guard — collect the header names already on the
        // response (lower-cased) so we never override a value already set.
        $already = [];
        foreach ( headers_list() as $sent ) {
            $name = strtolower( trim( (string) strtok( $sent, ':' ) ) );
            if ( $name !== '' ) {
                $already[ $name ] = true;
            }
        }

        foreach ( $desired as $name => $value ) {
            if ( isset( $already[ strtolower( $name ) ] ) ) {
                continue;
            }
            header( $name . ': ' . $value );
        }
    }

    /**
     * License-inactive alert — shown on every wp-admin page when the host
     * plugin's license is genuinely inactive (no key, no trial, no grace).
     *
     * ⚠️ COPY CORRECTED S366 (KI-40). This notice used to say "your storefront
     * is offline" and render as notice-error, because maintenance_gate() really
     * did 503 the shop. The gate is now off by default, so that sentence became
     * FALSE the moment the gate was disabled — a scarier claim than the truth,
     * on the one screen a panicking store owner reads first.
     *
     * What is actually true now: the store keeps selling as TitanCart Lite and
     * the Pro features are switched off until a key is entered. Downgraded to
     * notice-warning + is-dismissible accordingly.
     *
     * ⚠️ If anyone ever re-arms `titancart_storefront_maintenance_gate_enabled`, this
     * copy goes back to being a lie in the other direction. The two are one
     * fact with two renderings; they move together or not at all.
     *
     * Dismissal is PERSISTED since S385 (KI-64), but deliberately NOT permanent:
     * it is a snooze, keyed to the current unlicensed episode. See
     * ajax_dismiss_license_notice() and license_notice_episode().
     *
     * Hidden when:
     *   - License is active (the normal case)
     *   - Vendor site (auto-active via bypass)
     *   - User can't manage_options (avoid scaring shop managers)
     *   - This user snoozed it, within the snooze window, in THIS episode
     *
     * @return void
     */
    public static function license_inactive_notice(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }
        if ( function_exists( 'titancart_is_vendor_site' ) && titancart_is_vendor_site() ) {
            return;
        }

        if ( ! function_exists( 'titancart_core_license_active' ) ) {
            return;
        }

        try {
            if ( titancart_core_license_active() ) {
                // KI-64 — the store is licensed, so the current unlicensed
                // EPISODE is over. Closing it here is what makes every existing
                // snooze stale: if entitlement lapses again a new episode id is
                // minted and the notice re-surfaces for everyone, exactly as the
                // ticket requires ("re-show on any transition back into
                // unlicensed"). Guarded by a read so the normal licensed path
                // writes nothing.
                if ( get_option( self::LICENSE_NOTICE_EPISODE_OPTION, '' ) !== '' ) {
                    delete_option( self::LICENSE_NOTICE_EPISODE_OPTION );
                }
                return;
            }
        } catch ( \Throwable $e ) {
            return;
        }

        // KI-64 — per-user, per-episode snooze. A stale episode or an elapsed
        // window both fall through and render, so this can never silence the
        // notice forever.
        $episode = self::license_notice_episode();
        $snooze  = get_user_meta( get_current_user_id(), self::LICENSE_NOTICE_SNOOZE_META, true );
        if ( is_array( $snooze )
            && isset( $snooze['episode'], $snooze['until'] )
            && (string) $snooze['episode'] === $episode
            && (int) $snooze['until'] > time() ) {
            return;
        }

        $url       = admin_url( 'admin.php?page=titancart#/settings/account' );
        $subscribe = function_exists( 'titancart_subscribe_url' ) ? titancart_subscribe_url() : 'https://market.titancart.io/';

        ?>
        <div class="notice notice-warning is-dismissible" id="tc-license-notice" style="border-left-width:6px;padding:14px 18px;">
            <p style="margin:0 0 6px 0;font-size:14px;font-weight:600;color:#92400e;">
                <?php esc_html_e( 'TitanCart Pro features are switched off — your store is still open', 'titancart-lite' ); ?>
            </p>
            <p style="margin:0 0 10px 0;">
                <?php esc_html_e( 'Your storefront is serving customers and taking orders as TitanCart Lite. Pro features stay off until this install is licensed. Subscribe, or enter a license key you already have.', 'titancart-lite' ); ?>
            </p>
            <p style="margin:0;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
                <a href="<?php echo esc_url( $subscribe ); ?>" class="button button-primary" target="_blank" rel="noopener">
                    <?php esc_html_e( 'Subscribe to TitanCart', 'titancart-lite' ); ?>
                </a>
                <a href="<?php echo esc_url( $url ); ?>" class="button button-secondary">
                    <?php esc_html_e( 'Enter a license key', 'titancart-lite' ); ?>
                </a>
            </p>
        </div>
        <?php
        // WP.org R2: dismiss-handler JS is a NOWDOC (strtr() fills %%SLOT%% tokens)
        // emitted via wp_print_inline_script_tag (core API) — admin_notices renders
        // after admin_enqueue_scripts, so the enqueue pipeline is closed here.
        $tc_lic_ajax  = wp_json_encode( admin_url( 'admin-ajax.php' ) );
        $tc_lic_nonce = wp_json_encode( wp_create_nonce( self::LICENSE_NOTICE_NONCE ) );
        $tc_lic_js    = <<<'JS'
        (function () {
            var n = document.getElementById( 'tc-license-notice' );
            if ( ! n ) { return; }
            n.addEventListener( 'click', function ( e ) {
                if ( ! e.target || ! e.target.closest || ! e.target.closest( '.notice-dismiss' ) ) { return; }
                // Fire-and-forget: WP's own common.js already removes the notice
                // from this page. All this call does is persist the snooze so it
                // stays gone on the NEXT page load too.
                fetch( %%AJAX%%, {
                    method: 'POST',
                    credentials: 'same-origin',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: 'action=titancart_dismiss_license_notice&_wpnonce=' + encodeURIComponent( %%NONCE%% )
                } );
            } );
        })();
JS;
        $tc_lic_js = strtr( $tc_lic_js, [ '%%AJAX%%' => $tc_lic_ajax, '%%NONCE%%' => $tc_lic_nonce ] );
        wp_print_inline_script_tag( $tc_lic_js );
    }

    /**
     * KI-64 — the id of the current contiguous "unlicensed" episode.
     *
     * A snooze is scoped to an episode so that a store which lapses, gets
     * re-licensed, and lapses AGAIN shows the notice again rather than staying
     * silent on a dismissal the owner clicked months earlier. The episode is
     * opened lazily here (first unlicensed admin page load) and closed in
     * license_inactive_notice() the moment the license reads active.
     *
     * ⚠️ Do NOT "simplify" this to a bare boolean user-meta flag. A store owner
     * who dismisses once and forgets is a store owner who never re-licenses —
     * the notice is the only signal an unentitled store gets.
     *
     * @return string
     */
    private static function license_notice_episode(): string {
        $episode = (string) get_option( self::LICENSE_NOTICE_EPISODE_OPTION, '' );
        if ( $episode === '' ) {
            $episode = (string) time();
            // autoload=false: read only on unlicensed admin page loads.
            update_option( self::LICENSE_NOTICE_EPISODE_OPTION, $episode, false );
        }
        return $episode;
    }

    /**
     * KI-64 — persist a per-user snooze of the license-inactive notice.
     *
     * ⚠️ THE CLAMP IS LOAD-BEARING, NOT HYGIENE. The window is bounded to
     * 1..30 days so that neither a filter nor a future caller can turn this
     * into a permanent dismissal. "Dismissible forever" and "the only signal an
     * unentitled store gets" cannot both be true.
     *
     * @return void
     */
    public static function ajax_dismiss_license_notice(): void {
        check_ajax_referer( self::LICENSE_NOTICE_NONCE );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( [ 'message' => 'forbidden' ], 403 );
        }

        $days = (int) apply_filters(
            'titancart_license_notice_snooze_days',
            self::LICENSE_NOTICE_SNOOZE_DAYS
        );
        if ( $days < 1 )  { $days = 1; }
        if ( $days > 30 ) { $days = 30; }

        update_user_meta( get_current_user_id(), self::LICENSE_NOTICE_SNOOZE_META, [
            'episode' => self::license_notice_episode(),
            'until'   => time() + ( $days * DAY_IN_SECONDS ),
        ] );

        wp_send_json_success( [ 'snoozed_days' => $days ] );
    }


    /**
     * Add TitanCart links to the WordPress admin bar.
     *
     * Shows a "TitanCart" top-level node with sub-items pointing to
     * key admin pages and the live storefront.
     *
     * @param \WP_Admin_Bar $wp_admin_bar
     */
    public static function admin_bar_links( \WP_Admin_Bar $wp_admin_bar ): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $admin_url = admin_url( 'admin.php?page=titancart' );

        // ── Top-level node ──
        $wp_admin_bar->add_node( [
            'id'    => 'titancart',
            'title' => '<span class="ab-icon dashicons dashicons-cart" style="font-family:dashicons;top:2px;margin-right:4px;font-size:16px;"></span>' . __( 'TitanCart', 'titancart-lite' ),
            'href'  => $admin_url,
        ] );

        // ── Visit Store ──
        $shop_url = SeoService::get_shop_url();
        $wp_admin_bar->add_node( [
            'id'     => 'titancart-visit-store',
            'parent' => 'titancart',
            'title'  => __( '🛍 Visit Store', 'titancart-lite' ),
            'href'   => $shop_url,
            'meta'   => [ 'target' => '_blank' ],
        ] );

        // ── Dashboard ──
        $wp_admin_bar->add_node( [
            'id'     => 'titancart-dashboard',
            'parent' => 'titancart',
            'title'  => __( 'Dashboard', 'titancart-lite' ),
            'href'   => $admin_url . '#/dashboard',
        ] );

        // ── Products ──
        $wp_admin_bar->add_node( [
            'id'     => 'titancart-products',
            'parent' => 'titancart',
            'title'  => __( 'Products', 'titancart-lite' ),
            'href'   => $admin_url . '#/products',
        ] );

        // ── Orders ──
        $wp_admin_bar->add_node( [
            'id'     => 'titancart-orders',
            'parent' => 'titancart',
            'title'  => __( 'Orders', 'titancart-lite' ),
            'href'   => $admin_url . '#/orders',
        ] );

        // ── Customers ──
        $wp_admin_bar->add_node( [
            'id'     => 'titancart-customers',
            'parent' => 'titancart',
            'title'  => __( 'Customers', 'titancart-lite' ),
            'href'   => $admin_url . '#/customers',
        ] );

        // ── Settings ──
        $wp_admin_bar->add_node( [
            'id'     => 'titancart-settings',
            'parent' => 'titancart',
            'title'  => __( 'Settings', 'titancart-lite' ),
            'href'   => $admin_url . '#/settings',
        ] );
    }


    /**
     * Highest migration number defined in maybe_run_migrations().
     *
     * S468: a FRESH install stamps titancart_migration_version to this value at
     * activation (Installer::activate) — Schema::all() is the canonical
     * end-state, so replaying 63 historical migrations against a brand-new
     * schema is pure waste and, before init, actively harmful (dbDelta on the
     * SQLite driver emits broken ALTERs; Migration 23 creates pages, which is
     * unsupported pre-init). ADDING A MIGRATION? Bump this constant in the
     * same commit, and make sure Schema.php already reflects the end state.
     */
    public const MIGRATION_LATEST = 68;

    /**
     * Run pending column migrations that dbDelta cannot handle.
     *
     * Uses a version flag so each migration only runs once.
     * Wrapped in try-catch so a migration failure can NEVER kill route registration.
     * Public because Migration 23's pre-init guard re-hooks this method on 'init'
     * (S468: wp_insert_post before init is unsupported — $wp_rewrite does not
     * exist yet — so any run that reaches a page-creating migration before init
     * defers itself). Re-entry is safe: every block is version-gated.
     */
    public static function maybe_run_migrations(): void {
        try {
            $migration_version = (int) get_option( 'titancart_migration_version', 0 );

            if ( $migration_version < 1 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_product_filters';

                // Check if table exists first.
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'display_type'" );
                    if ( $col && strpos( $col->Type, 'size_pills' ) === false ) {
                        $wpdb->query(
                            "ALTER TABLE {$table}
                             MODIFY COLUMN display_type ENUM('checkbox','slider','color','range','size_pills') NOT NULL DEFAULT 'checkbox'"
                        );
                    }
                }

                update_option( 'titancart_migration_version', 1, false );
            }

            // Migration 2: Add columns to tc_tax_classes for tax-class parity
            if ( $migration_version < 2 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_tax_classes';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    // Add registration_number if missing
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'registration_number'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN registration_number VARCHAR(64) NOT NULL DEFAULT '' AFTER handle" );
                    }
                    // Add priority if missing
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'priority'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN priority INT UNSIGNED NOT NULL DEFAULT 0 AFTER registration_number" );
                    }
                    // Add rates_depend_on if missing
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'rates_depend_on'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN rates_depend_on ENUM('shipping','billing') NOT NULL DEFAULT 'shipping' AFTER priority" );
                    }
                    // Add price_includes_tax if missing
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'price_includes_tax'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN price_includes_tax TINYINT(1) NOT NULL DEFAULT 0 AFTER rates_depend_on" );
                    }
                }
                update_option( 'titancart_migration_version', 2, false );
            }

            // Migration 3: Add price_type column to tc_shipping_rate_conditions
            if ( $migration_version < 3 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_shipping_rate_conditions';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'price_type'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN price_type ENUM('fixed','percent') NOT NULL DEFAULT 'fixed' AFTER price" );
                    }
                }
                update_option( 'titancart_migration_version', 3, false );
            }

            // Migration 4: Add thousands_sep, decimal_sep, position columns to tc_currencies
            if ( $migration_version < 4 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_currencies';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'thousands_sep'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN thousands_sep VARCHAR(4) NOT NULL DEFAULT ',' AFTER decimals" );
                    }
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'decimal_sep'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN decimal_sep VARCHAR(4) NOT NULL DEFAULT '.' AFTER thousands_sep" );
                    }
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'position'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN position INT UNSIGNED NOT NULL DEFAULT 0 AFTER is_primary" );
                    }
                }
                update_option( 'titancart_migration_version', 4, false );
            }

            // Migration 5: Create tc_customer_fields table + seed defaults
            if ( $migration_version < 5 ) {
                global $wpdb;
                $p     = $wpdb->prefix . 'tc_';
                $table = $p . 'customer_fields';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( ! $exists ) {
                    $charset = $wpdb->get_charset_collate();
                    $wpdb->query( "CREATE TABLE {$table} (
                        id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        section      ENUM('shipping','contact','billing') NOT NULL,
                        field_name   VARCHAR(64)  NOT NULL,
                        field_type   VARCHAR(32)  NOT NULL DEFAULT 'input',
                        description  VARCHAR(128) NOT NULL DEFAULT '',
                        position     INT UNSIGNED NOT NULL DEFAULT 0,
                        is_visible   TINYINT(1)   NOT NULL DEFAULT 0,
                        is_required  TINYINT(1)   NOT NULL DEFAULT 0,
                        is_system    TINYINT(1)   NOT NULL DEFAULT 0,
                        status       ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_section_field (section, field_name),
                        KEY idx_section (section)
                    ) {$charset}" );
                }
                // Seed if empty.
                $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
                if ( $count === 0 ) {
                    $rows = [
                        ['shipping','first_name','input','First name',10,1,0,1],['shipping','last_name','input','Last name',20,1,0,1],
                        ['shipping','address_1','input','Address',30,1,1,1],['shipping','address_2','input','Address',40,0,0,0],
                        ['shipping','city','input','City',50,1,1,1],['shipping','country','country','Country',60,1,1,1],
                        ['shipping','state','state','State/province',70,1,1,1],['shipping','zipcode','zipcode','Zip/postal code',80,1,1,1],
                        ['shipping','phone','phone','Phone',90,0,0,0],['shipping','address_type','select','Address type',100,0,0,0],
                        ['contact','first_name','input','First name',10,1,1,1],['contact','last_name','input','Last name',20,1,0,1],
                        ['contact','company','input','Company',30,0,0,0],['contact','phone','phone','Phone',40,1,0,0],
                        ['contact','email','email','E-mail',50,1,1,1],
                        ['billing','first_name','input','First name',10,0,0,1],['billing','last_name','input','Last name',20,0,0,1],
                        ['billing','address_1','input','Address',30,0,0,1],['billing','address_2','input','Address',40,0,0,0],
                        ['billing','city','input','City',50,0,0,1],['billing','country','country','Country',60,0,0,1],
                        ['billing','state','state','State/province',70,0,0,1],['billing','zipcode','zipcode','Zip/postal code',80,0,0,1],
                        ['billing','phone','phone','Phone',90,0,0,0],
                    ];
                    foreach ( $rows as $r ) {
                        $wpdb->insert( $table, [
                            'section'=>$r[0],'field_name'=>$r[1],'field_type'=>$r[2],'description'=>$r[3],
                            'position'=>$r[4],'is_visible'=>$r[5],'is_required'=>$r[6],'is_system'=>$r[7],'status'=>'active',
                        ] );
                    }
                }
                update_option( 'titancart_migration_version', 5, false );
            }

            // Migration 6: Add `type` column to tc_order_statuses + seed fulfillment & gift card statuses
            if ( $migration_version < 6 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_order_statuses';
                $trans = $wpdb->prefix . 'tc_order_status_translations';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'type'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN type ENUM('order','fulfillment','gift_card') NOT NULL DEFAULT 'order' AFTER handle" );
                        $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_type (type)" );
                    }
                    // Seed fulfillment statuses if none exist.
                    $fc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE type = 'fulfillment'" );
                    if ( $fc === 0 ) {
                        $ff = [
                            ['ff_pending','Pending','#D9A441',5,1],
                            ['picked_up','Picked Up','#0891B2',10,0],
                            ['packed','Packed','#71797E',20,0],
                            ['shipped','Shipped','#1F8A70',30,0],
                        ];
                        foreach ( $ff as $f ) {
                            $wpdb->insert( $table, ['handle'=>$f[0],'type'=>'fulfillment','color'=>$f[2],'position'=>$f[3],'is_default'=>$f[4],'notify_customer'=>0,'stock_action'=>'none'] );
                            $sid = $wpdb->insert_id;
                            if ( $sid ) $wpdb->insert( $trans, ['status_id'=>$sid,'language_code'=>'en','name'=>$f[1]] );
                        }
                    }
                    // Seed gift card statuses if none exist.
                    $gc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE type = 'gift_card'" );
                    if ( $gc === 0 ) {
                        $gg = [
                            ['gc_pending','Pending','#D9A441',10],
                            ['gc_active','Active','#1F8A70',20],
                            ['gc_disabled','Disabled','#71797E',30],
                            ['gc_used','Used','#1F6AE1',40],
                        ];
                        foreach ( $gg as $g ) {
                            $wpdb->insert( $table, ['handle'=>$g[0],'type'=>'gift_card','color'=>$g[2],'position'=>$g[3],'is_default'=>($g[3]===10?1:0),'notify_customer'=>0,'stock_action'=>'none'] );
                            $sid = $wpdb->insert_id;
                            if ( $sid ) $wpdb->insert( $trans, ['status_id'=>$sid,'language_code'=>'en','name'=>$g[1]] );
                        }
                    }
                }
                update_option( 'titancart_migration_version', 6, false );
            }

            // Migration 7: Add "Pending" to fulfillment statuses as default
            if ( $migration_version < 7 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_order_statuses';
                $trans = $wpdb->prefix . 'tc_order_status_translations';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    // Check if ff_pending already exists (fresh installs from updated migration 6)
                    $has_pending = $wpdb->get_var( "SELECT id FROM {$table} WHERE handle = 'ff_pending' LIMIT 1" );
                    if ( ! $has_pending ) {
                        // Insert Pending as new fulfillment default
                        $wpdb->insert( $table, ['handle'=>'ff_pending','type'=>'fulfillment','color'=>'#D9A441','position'=>5,'is_default'=>1,'notify_customer'=>0,'stock_action'=>'none'] );
                        $sid = $wpdb->insert_id;
                        if ( $sid ) {
                            $wpdb->insert( $trans, ['status_id'=>$sid,'language_code'=>'en','name'=>'Pending'] );
                        }
                        // Un-default "Picked Up"
                        $wpdb->query( "UPDATE {$table} SET is_default = 0 WHERE handle = 'picked_up' AND type = 'fulfillment'" );
                    }
                }
                update_option( 'titancart_migration_version', 7, false );
            }

            // Migration 8: Expand tc_logs table with user_id, action, object tracking, IP, user_agent
            if ( $migration_version < 8 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_logs';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $cols_to_add = [
                        'action'      => "VARCHAR(64) NOT NULL DEFAULT '' AFTER channel",
                        'object_type' => "VARCHAR(64) NOT NULL DEFAULT '' AFTER context",
                        'object_id'   => "BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER object_type",
                        'user_id'     => "BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER object_id",
                        'ip_address'  => "VARCHAR(45) NOT NULL DEFAULT '' AFTER user_id",
                        'user_agent'  => "VARCHAR(512) NOT NULL DEFAULT '' AFTER ip_address",
                    ];
                    foreach ( $cols_to_add as $col_name => $col_def ) {
                        // WP.org R2 §8: identifiers cannot be bound by prepare().
                        // Both halves come from the hardcoded literal map above
                        // (never user input); the preg guards pin the column
                        // name to an identifier shape and the definition to the
                        // DDL character set before either is interpolated.
                        if ( ! preg_match( '/^[a-z0-9_]+$/', $col_name ) || ! preg_match( "/^[A-Za-z0-9_ (),'\\.]+$/", $col_def ) ) {
                            continue;
                        }
                        $col = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $col_name ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table = $wpdb->prefix . literal.
                        if ( ! $col ) {
                            $wpdb->query( "ALTER TABLE {$table} ADD COLUMN {$col_name} {$col_def}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL over whitelisted literals, preg-guarded above; identifiers are not preparable.
                        }
                    }
                    // Add indexes if missing.
                    $indexes = $wpdb->get_results( "SHOW INDEX FROM {$table}", ARRAY_A );
                    $existing_keys = array_column( $indexes, 'Key_name' );
                    if ( ! in_array( 'idx_action', $existing_keys, true ) ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_action (action)" );
                    }
                    if ( ! in_array( 'idx_user_id', $existing_keys, true ) ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_user_id (user_id)" );
                    }
                    if ( ! in_array( 'idx_object', $existing_keys, true ) ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_object (object_type, object_id)" );
                    }
                }
                update_option( 'titancart_migration_version', 8, false );
            }

            // Migration 9: Create tc_social_providers table
            if ( $migration_version < 9 ) {
                global $wpdb;
                $table   = $wpdb->prefix . 'tc_social_providers';
                $exists  = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( ! $exists ) {
                    $charset = $wpdb->get_charset_collate();
                    $wpdb->query( "CREATE TABLE {$table} (
                        id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        provider      VARCHAR(32)  NOT NULL,
                        name          VARCHAR(128) NOT NULL DEFAULT '',
                        client_id     VARCHAR(255) NOT NULL DEFAULT '',
                        client_secret VARCHAR(255) NOT NULL DEFAULT '',
                        status        ENUM('active','disabled') NOT NULL DEFAULT 'disabled',
                        position      INT UNSIGNED NOT NULL DEFAULT 0,
                        created_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_provider (provider)
                    ) {$charset}" );
                }
                update_option( 'titancart_migration_version', 9, false );
            }

            // Migration 10: Create tc_notification_settings + tc_email_templates tables
            if ( $migration_version < 10 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();

                $t1 = $wpdb->prefix . 'tc_notification_settings';
                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t1 ) ) ) {
                    $wpdb->query( "CREATE TABLE {$t1} (
                        event_id   VARCHAR(64)  NOT NULL,
                        receiver   ENUM('customer','admin') NOT NULL DEFAULT 'customer',
                        transport  VARCHAR(32)  NOT NULL DEFAULT 'email',
                        enabled    TINYINT(1)   NOT NULL DEFAULT 1,
                        PRIMARY KEY (event_id, receiver, transport)
                    ) {$charset}" );
                }

                $t2 = $wpdb->prefix . 'tc_email_templates';
                // Always drop — old schema had handle/event columns incompatible with new slug/receiver schema
                $wpdb->query( "DROP TABLE IF EXISTS {$t2}" );
                $wpdb->query( "CREATE TABLE {$t2} (
                        id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        slug       VARCHAR(64)  NOT NULL,
                        receiver   ENUM('customer','admin') NOT NULL DEFAULT 'customer',
                        name       VARCHAR(255) NOT NULL DEFAULT '',
                        subject    VARCHAR(255) NOT NULL DEFAULT '',
                        body_html  LONGTEXT,
                        status     ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_slug_receiver (slug, receiver)
                    ) {$charset}" );

                update_option( 'titancart_migration_version', 10, false );
            }

            // Migration 11: Create tc_countries + tc_states tables, seed data
            if ( $migration_version < 11 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'countries' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}countries (
                        code   CHAR(2)      NOT NULL,
                        code3  CHAR(3)      NOT NULL DEFAULT '',
                        name   VARCHAR(128) NOT NULL,
                        status ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        PRIMARY KEY (code),
                        KEY idx_status (status)
                    ) {$charset}" );
                }

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'states' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}states (
                        id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        country_code CHAR(2)      NOT NULL,
                        code         VARCHAR(10)  NOT NULL,
                        name         VARCHAR(128) NOT NULL,
                        status       ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        PRIMARY KEY (id),
                        KEY idx_country_code (country_code),
                        UNIQUE KEY uq_country_state (country_code, code)
                    ) {$charset}" );
                }

                // Seed countries (idempotent — skips if already populated).
                \TitanCart\DB\CountrySeeder::seed();

                update_option( 'titancart_migration_version', 11, false );
            }

            // Migration 12: Create tc_shipments + tc_shipment_items tables
            if ( $migration_version < 12 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'shipments' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}shipments (
                        id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        order_id         BIGINT UNSIGNED NOT NULL,
                        shipping_method_id BIGINT UNSIGNED NULL,
                        status_id        BIGINT UNSIGNED NULL,
                        tracking_number  VARCHAR(128)    NOT NULL DEFAULT '',
                        carrier          VARCHAR(64)     NOT NULL DEFAULT '',
                        comments         TEXT            NULL,
                        shipped_at       DATETIME        NULL,
                        created_at       DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at       DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_order_id (order_id),
                        KEY idx_status_id (status_id)
                    ) {$charset}" );
                }

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'shipment_items' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}shipment_items (
                        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        shipment_id     BIGINT UNSIGNED NOT NULL,
                        order_item_id   BIGINT UNSIGNED NOT NULL,
                        product_id      BIGINT UNSIGNED NOT NULL,
                        quantity        INT UNSIGNED    NOT NULL DEFAULT 0,
                        PRIMARY KEY (id),
                        KEY idx_shipment_id (shipment_id),
                        KEY idx_order_item_id (order_item_id)
                    ) {$charset}" );
                }

                update_option( 'titancart_migration_version', 12, false );
            }

            // Migration 13: Create tc_gift_cards + tc_gift_card_log tables
            if ( $migration_version < 13 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'gift_cards' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}gift_cards (
                        id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        code             VARCHAR(32)     NOT NULL,
                        amount           DECIMAL(12,2)  NOT NULL DEFAULT 0.00,
                        balance          DECIMAL(12,2)  NOT NULL DEFAULT 0.00,
                        sender_name      VARCHAR(128)    NOT NULL DEFAULT '',
                        sender_email     VARCHAR(255)    NOT NULL DEFAULT '',
                        recipient_name   VARCHAR(128)    NOT NULL DEFAULT '',
                        recipient_email  VARCHAR(255)    NOT NULL DEFAULT '',
                        message          TEXT            NULL,
                        send_via         ENUM('email','postal') NOT NULL DEFAULT 'email',
                        status           ENUM('pending','active','used','disabled') NOT NULL DEFAULT 'pending',
                        order_id         BIGINT UNSIGNED NULL,
                        customer_id      BIGINT UNSIGNED NULL,
                        expires_at       DATETIME        NULL,
                        sent_at          DATETIME        NULL,
                        created_at       DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at       DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_code (code),
                        KEY idx_status (status),
                        KEY idx_order_id (order_id),
                        KEY idx_customer_id (customer_id)
                    ) {$charset}" );
                }

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'gift_card_log' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}gift_card_log (
                        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        gift_card_id    BIGINT UNSIGNED NOT NULL,
                        order_id        BIGINT UNSIGNED NULL,
                        amount          DECIMAL(12,2)  NOT NULL DEFAULT 0.00,
                        balance_after   DECIMAL(12,2)  NOT NULL DEFAULT 0.00,
                        action          ENUM('created','redeemed','refunded','adjusted','disabled') NOT NULL,
                        note            VARCHAR(255)    NOT NULL DEFAULT '',
                        created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_gift_card_id (gift_card_id)
                    ) {$charset}" );
                }

                update_option( 'titancart_migration_version', 13, false );
            }

            // Migration 14: Create reward points tables
            if ( $migration_version < 14 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'reward_point_balances' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}reward_point_balances (
                        id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        customer_id   BIGINT UNSIGNED NOT NULL,
                        balance       INT             NOT NULL DEFAULT 0,
                        lifetime      INT             NOT NULL DEFAULT 0,
                        updated_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_customer (customer_id)
                    ) {$charset}" );
                }

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'reward_point_log' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}reward_point_log (
                        id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        customer_id   BIGINT UNSIGNED NOT NULL,
                        order_id      BIGINT UNSIGNED NULL,
                        points        INT             NOT NULL DEFAULT 0,
                        action        ENUM('earned','redeemed','adjusted','expired','refunded') NOT NULL,
                        reason        VARCHAR(255)    NOT NULL DEFAULT '',
                        created_by    BIGINT UNSIGNED NULL,
                        created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_customer_id (customer_id),
                        KEY idx_order_id (order_id),
                        KEY idx_action (action)
                    ) {$charset}" );
                }

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'reward_point_rules' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}reward_point_rules (
                        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        event           VARCHAR(64)     NOT NULL,
                        name            VARCHAR(255)    NOT NULL DEFAULT '',
                        points_amount   INT             NOT NULL DEFAULT 0,
                        points_type     ENUM('fixed','per_dollar') NOT NULL DEFAULT 'fixed',
                        min_order       DECIMAL(12,2)  NOT NULL DEFAULT 0.00,
                        usergroup_id    BIGINT UNSIGNED NULL,
                        status          ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        position        INT UNSIGNED    NOT NULL DEFAULT 0,
                        created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_event (event),
                        KEY idx_status (status)
                    ) {$charset}" );
                }

                // Seed default earning rules.
                $rc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$p}reward_point_rules" ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $p = $wpdb->prefix . TITANCART_TABLE_PREFIX (constants, never user input); migration seed check.
                if ( $rc === 0 ) {
                    $defaults = [
                        [ 'order_placed',     'Points per dollar spent',        1, 'per_dollar', 0,    1 ],
                        [ 'account_created',  'Account registration bonus',   100, 'fixed',      0,    2 ],
                        [ 'review_posted',    'Product review bonus',          50, 'fixed',      0,    3 ],
                    ];
                    foreach ( $defaults as $d ) {
                        $wpdb->insert( $p . 'reward_point_rules', [
                            'event'         => $d[0],
                            'name'          => $d[1],
                            'points_amount' => $d[2],
                            'points_type'   => $d[3],
                            'min_order'     => $d[4],
                            'position'      => $d[5],
                            'status'        => 'active',
                        ] );
                    }
                }

                update_option( 'titancart_migration_version', 14, false );
            }

            // Migration 15: Create tc_tags + tc_product_tags tables
            if ( $migration_version < 15 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'tags' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}tags (
                        id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        name          VARCHAR(128)    NOT NULL,
                        slug          VARCHAR(128)    NOT NULL,
                        product_count INT UNSIGNED    NOT NULL DEFAULT 0,
                        created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_slug (slug),
                        KEY idx_name (name)
                    ) {$charset}" );
                }

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'product_tags' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}product_tags (
                        product_id  BIGINT UNSIGNED NOT NULL,
                        tag_id      BIGINT UNSIGNED NOT NULL,
                        PRIMARY KEY (product_id, tag_id),
                        KEY idx_tag_id (tag_id)
                    ) {$charset}" );
                }

                update_option( 'titancart_migration_version', 15, false );
            }

            // Migration 16: Create tc_seo_redirects table
            if ( $migration_version < 16 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'seo_redirects' ) ) ) {
                    $wpdb->query( "CREATE TABLE {$p}seo_redirects (
                        id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        from_url    VARCHAR(512)    NOT NULL,
                        to_url      VARCHAR(512)    NOT NULL,
                        type        SMALLINT        NOT NULL DEFAULT 301,
                        hits        INT UNSIGNED    NOT NULL DEFAULT 0,
                        status      ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_from_url (from_url(191)),
                        KEY idx_status (status)
                    ) {$charset}" );
                }

                update_option( 'titancart_migration_version', 16, false );
            }

            // Migration 17: Create newsletter/email campaign tables
            if ( $migration_version < 17 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $p = $wpdb->prefix . 'tc_';

                // tc_subscribers
                if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'subscribers' ) ) ) {
                    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                    dbDelta( \TitanCart\DB\Schema::all() );
                }

                update_option( 'titancart_migration_version', 17, false );
            }

            // Migration 18: Create tc_todos table (Applications — To Do)
            if ( $migration_version < 18 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );
                update_option( 'titancart_migration_version', 18, false );
            }

            // Migration 19: Create tc_calendar_events table (Applications — Calendar)
            if ( $migration_version < 19 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );
                update_option( 'titancart_migration_version', 19, false );
            }

            // Migration 20: Create tc_comments table (Comments & Discussions)
            if ( $migration_version < 20 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );
                update_option( 'titancart_migration_version', 20, false );
            }

            // Migration 21: Add age_restricted + age_limit columns to tc_products (Age Verification extension)
            if ( $migration_version < 21 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_products';
                $cols  = $wpdb->get_col( "DESCRIBE {$table}", 0 );
                if ( ! in_array( 'age_restricted', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN age_restricted TINYINT(1) NOT NULL DEFAULT 0 AFTER free_shipping" );
                }
                if ( ! in_array( 'age_limit', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN age_limit SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER age_restricted" );
                }
                update_option( 'titancart_migration_version', 21, false );
            }

            // Migration 22: Add details_view, search_words, promo_text columns to tc_products
            if ( $migration_version < 22 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_products';
                $cols  = $wpdb->get_col( "SHOW COLUMNS FROM {$table}" );
                if ( ! in_array( 'details_view', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN details_view VARCHAR(20) NOT NULL DEFAULT '' AFTER views_count" );
                }
                if ( ! in_array( 'search_words', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN search_words TEXT NULL AFTER details_view" );
                }
                if ( ! in_array( 'promo_text', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN promo_text TEXT NULL AFTER search_words" );
                }
                if ( ! in_array( 'videos', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN videos JSON NULL AFTER promo_text" );
                }
                update_option( 'titancart_migration_version', 22, false );
            }

            // Migration 23: Auto-create essential storefront pages if missing.
            // Note: Shop, Cart, Checkout, My Account, Wishlist, Search, and Order Received
            // are handled by the plugin's rewrite rules (StorefrontModule::register_extra_rewrite_rules)
            // and do NOT need WordPress pages. Only pages without rewrite rules need creation.
            //
            // DEPRECATED CREATION PATH (S345 — Page Manager): the page CREATION
            // below now delegates to the canonical PageRegistry::ensure() — the
            // single idempotent adopt-or-create implementation (S343) — instead
            // of a hand-rolled wp_insert_post loop. This migration is RETAINED
            // (not deleted) as a version marker for historical installs AND
            // because it still owns the one thing the registry deliberately does
            // not: binding the WordPress reading settings (static front page /
            // posts page). New page creation must be added to PageRegistry, never
            // by extending this migration.
            if ( $migration_version < 23 ) {
                // S468 (Playground/WP.org R3 testing): creating pages requires a
                // constructed $wp_rewrite — wp_insert_post() before 'init' warns
                // on null rewrite properties and page permalink resolution
                // fatals in get_page_permastruct(). If this run reached here
                // pre-init (plugins_loaded boot on a legacy site below v23),
                // defer the REMAINDER of the run to init; version is still <23
                // so this block re-enters there. Fresh installs never reach
                // this: activation stamps MIGRATION_LATEST.
                if ( ! did_action( 'init' ) ) {
                    add_action( 'init', [ self::class, 'maybe_run_migrations' ], 0 );
                    return;
                }
                // Create/adopt homepage, blog + gift_cards through the registry's
                // one idempotent path (adopts an existing page, never duplicates;
                // binds the titancart_page_* option for the option-bound pages).
                if ( class_exists( \TitanCart\Modules\Pages\PageRegistry::class ) ) {
                    foreach ( [ 'homepage', 'blog', 'gift_cards' ] as $registry_key ) {
                        $descriptor = \TitanCart\Modules\Pages\PageRegistry::get( $registry_key );
                        if ( $descriptor ) {
                            \TitanCart\Modules\Pages\PageRegistry::ensure( $descriptor );
                        }
                    }
                }

                // Bind the WordPress reading settings: static front page + blog
                // page. This binding is Migration 23's own responsibility — the
                // registry's ensure_all( bound_only:true ) deliberately never
                // touches structural pages, so it stays owned here.
                $hp   = get_page_by_path( 'homepage', OBJECT, 'page' );
                $blog = get_page_by_path( 'blog', OBJECT, 'page' );

                if ( $hp ) {
                    update_option( 'show_on_front', 'page' );
                    update_option( 'page_on_front', $hp->ID );
                }
                if ( $blog ) {
                    update_option( 'page_for_posts', $blog->ID );
                }

                update_option( 'titancart_migration_version', 23, false );
            }

            // Migration 24: Add 'I' (Image) to product_features type ENUM for image-based variant selectors.
            if ( $migration_version < 24 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_features';
                $col   = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'type'" );
                if ( $col && strpos( $col->Type, "'I'" ) === false ) {
                    $wpdb->query(
                        "ALTER TABLE {$table}
                         MODIFY COLUMN type ENUM('G','C','M','S','N','E','T','O','D','I') NOT NULL DEFAULT 'T'"
                    );
                }
                update_option( 'titancart_migration_version', 24, false );
            }

            // Migration 25: Add image_id to product_feature_values for per-product variant images.
            if ( $migration_version < 25 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_feature_values';
                $col   = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'image_id'" );
                if ( ! $col ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN image_id BIGINT UNSIGNED NULL AFTER variant_id" );
                }
                update_option( 'titancart_migration_version', 25, false );
            }

            // Migration 26: Add start_expanded to product_filters for collapsible filter sections.
            if ( $migration_version < 26 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_filters';
                $col   = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'start_expanded'" );
                if ( ! $col ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN start_expanded TINYINT(1) NOT NULL DEFAULT 0 AFTER position" );
                }
                update_option( 'titancart_migration_version', 26, false );
            }

            // Migration 27: Create RMA (Returns) tables + seed default reasons.
            if ( $migration_version < 27 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                $schema = \TitanCart\DB\Schema::all();
                // Run dbDelta for ALL tables — safe on existing tables, creates new ones.
                foreach ( $schema as $sql ) {
                    dbDelta( $sql );
                }

                // Seed default return reasons if table is empty.
                global $wpdb;
                $reasons_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'return_reasons';
                $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$reasons_table}" );
                if ( $count === 0 ) {
                    $defaults = [
                        [ 'name' => 'Defective or damaged',      'position' => 1 ],
                        [ 'name' => 'Wrong item received',       'position' => 2 ],
                        [ 'name' => 'Changed my mind',           'position' => 3 ],
                        [ 'name' => 'No longer needed',          'position' => 4 ],
                        [ 'name' => 'Arrived too late',          'position' => 5 ],
                        [ 'name' => 'Item doesn\'t match description', 'position' => 6 ],
                    ];
                    foreach ( $defaults as $reason ) {
                        $wpdb->insert( $reasons_table, [
                            'name'     => $reason['name'],
                            'position' => $reason['position'],
                            'status'   => 'active',
                        ], [ '%s', '%d', '%s' ] );
                    }
                }
                update_option( 'titancart_migration_version', 27, false );
            }

            // Migration 28: Add exchange_items JSON column to tc_returns for exchange product selections.
            if ( $migration_version < 28 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'returns';
                $col   = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'exchange_items'" );
                if ( ! $col ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN exchange_items JSON NULL AFTER admin_notes" );
                }
                update_option( 'titancart_migration_version', 28, false );
            }

            // Migration 29: Add replacement_order_id column to tc_returns for exchange-with-price-difference flow.
            // When a customer picks a higher-priced replacement, they are redirected to cart and pay the
            // difference through normal checkout. The resulting order is linked back to the return here.
            if ( $migration_version < 29 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'returns';
                $col   = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'replacement_order_id'" );
                if ( ! $col ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN replacement_order_id BIGINT UNSIGNED NULL AFTER exchange_items" );
                }
                // Add the index only if missing.
                $indexes = $wpdb->get_results( "SHOW INDEX FROM {$table}", ARRAY_A );
                $existing_keys = array_column( $indexes, 'Key_name' );
                if ( ! in_array( 'idx_replacement_order_id', $existing_keys, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_replacement_order_id (replacement_order_id)" );
                }
                update_option( 'titancart_migration_version', 29, false );
            }

            // Migration 30: Drop legacy meta_keywords column from tc_product_translations.
            // Removed Session 99 — Google has ignored <meta name="keywords"> for SEO since
            // 2009; categories never had this column (asymmetry); admin UI + REST + import
            // wiring all removed in the same change. Idempotent: only ALTERs if column exists.
            if ( $migration_version < 30 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_translations';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'meta_keywords'" );
                    if ( $col ) {
                        $wpdb->query( "ALTER TABLE {$table} DROP COLUMN meta_keywords" );
                    }
                }
                update_option( 'titancart_migration_version', 30, false );
            }

            // Migration 31: Add subscriptions_only column to tc_products. Session 102 — this
            // flag suppresses the "One-Time Purchase" pill on the storefront PDP so a product
            // can be sold via subscription plans only. Default 0 means existing products are
            // unaffected (the pill renders as before). The TitanCart Subscriptions extension
            // (StorefrontIntegration::render_plan_selector) reads this flag to decide whether
            // to render the One-Time pill and which pill to mark as the default-active one.
            // Schema position: AFTER is_downloadable (the canonical digital-product flag).
            if ( $migration_version < 31 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'products';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'subscriptions_only'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN subscriptions_only TINYINT(1) NOT NULL DEFAULT 0 AFTER is_downloadable" );
                    }
                }
                update_option( 'titancart_migration_version', 31, false );
            }

            // Migration 32: Session 120 — fill the schema gap behind ~18 product editor fields
            // that were silently dropped on save (matrix Tier-3 bug). Adds explicit columns to
            // tc_products for: zero-price action, out-of-stock action, options/exception types,
            // availability date, price-per-unit triplet, and the 10-column reward-points family.
            // Sean's call (S120): explicit columns over JSON `meta` blob — typed, queryable,
            // matches existing extension-flag pattern (track_inventory / is_downloadable / etc.).
            // Idempotent: each ALTER guarded by SHOW COLUMNS check.
            if ( $migration_version < 32 ) {
                global $wpdb;
                $table  = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'products';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $columns = [
                        // [name, definition, AFTER target] — single ADD COLUMN per entry, idempotent.
                        [ 'zero_price_action',
                          "ENUM('do_not_allow','allow','ask') NOT NULL DEFAULT 'do_not_allow'",
                          'price' ],
                        [ 'out_of_stock_actions',
                          "ENUM('none','buy','subscribe') NOT NULL DEFAULT 'none'",
                          'stock_status' ],
                        [ 'options_type',
                          "ENUM('P','S','N') NOT NULL DEFAULT 'P'",
                          'type' ],
                        [ 'exceptions_type',
                          "ENUM('F','A') NOT NULL DEFAULT 'F'",
                          'options_type' ],
                        [ 'avail_since',
                          "DATETIME NULL",
                          'created_at' ],
                        [ 'unit_name',
                          "VARCHAR(64) NOT NULL DEFAULT ''",
                          'weight_unit' ],
                        [ 'units_in_product',
                          "DECIMAL(12,4) NOT NULL DEFAULT 0.0000",
                          'unit_name' ],
                        [ 'show_price_per',
                          "DECIMAL(12,4) NOT NULL DEFAULT 0.0000",
                          'units_in_product' ],
                        // Reward Points per-product overrides (10 columns).
                        [ 'rp_allow_payment',
                          "TINYINT(1) NOT NULL DEFAULT 0",
                          'subscriptions_only' ],
                        [ 'rp_override_per',
                          "TINYINT(1) NOT NULL DEFAULT 0",
                          'rp_allow_payment' ],
                        [ 'rp_price',
                          "INT UNSIGNED NOT NULL DEFAULT 0",
                          'rp_override_per' ],
                        [ 'rp_override_earn',
                          "TINYINT(1) NOT NULL DEFAULT 0",
                          'rp_price' ],
                        [ 'rp_earn_all',
                          "INT UNSIGNED NOT NULL DEFAULT 0",
                          'rp_override_earn' ],
                        [ 'rp_earn_all_type',
                          "ENUM('absolute','percent') NOT NULL DEFAULT 'absolute'",
                          'rp_earn_all' ],
                        [ 'rp_earn_guest',
                          "INT UNSIGNED NOT NULL DEFAULT 0",
                          'rp_earn_all_type' ],
                        [ 'rp_earn_guest_type',
                          "ENUM('absolute','percent') NOT NULL DEFAULT 'absolute'",
                          'rp_earn_guest' ],
                        [ 'rp_earn_reg',
                          "INT UNSIGNED NOT NULL DEFAULT 0",
                          'rp_earn_guest_type' ],
                        [ 'rp_earn_reg_type',
                          "ENUM('absolute','percent') NOT NULL DEFAULT 'absolute'",
                          'rp_earn_reg' ],
                    ];
                    foreach ( $columns as [ $name, $def, $after ] ) {
                        // Same shape as Migration 8 (WP.org R2 §8): identifiers are
                        // not preparable; all three halves are hardcoded literals
                        // from the map above and are preg-pinned to identifier /
                        // DDL character sets before interpolation.
                        if ( ! preg_match( '/^[a-z0-9_]+$/', $name ) || ! preg_match( '/^[a-z0-9_]+$/', $after ) || ! preg_match( "/^[A-Za-z0-9_ (),'\\.]+$/", $def ) ) {
                            continue;
                        }
                        $col = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $name ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table = $wpdb->prefix . literal.
                        if ( ! $col ) {
                            $wpdb->query( "ALTER TABLE {$table} ADD COLUMN {$name} {$def} AFTER {$after}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL over whitelisted literals, preg-guarded above; identifiers are not preparable.
                        }
                    }
                }
                update_option( 'titancart_migration_version', 32, false );
            }

            // Migration 33: Session 120 — fix the Bulk Buys end-to-end break. The frontend was
            // sending {qty,value,type,usergroup} into a column-stripped payload. New shape:
            // prices: [{ usergroup_id, min_quantity, discount_type, discount_value }].
            // Adds discount_type ENUM + discount_value DECIMAL to tc_product_prices.
            // The legacy `price` column is retained as the cached effective unit price for
            // both modes (when type=absolute, equals discount_value; when type=percent, is
            // computed as base_price * (1 - value/100) at save time).
            if ( $migration_version < 33 ) {
                global $wpdb;
                $table  = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_prices';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'discount_type'" );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$table}
                             ADD COLUMN discount_type ENUM('absolute','percent') NOT NULL DEFAULT 'absolute' AFTER price"
                        );
                    }
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'discount_value'" );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$table}
                             ADD COLUMN discount_value DECIMAL(12,4) NOT NULL DEFAULT 0.0000 AFTER discount_type"
                        );
                    }
                    // Backfill discount_value from existing `price` for any rows that have
                    // discount_value=0 — these are pre-S120 rows where the two columns hold
                    // the same number for the absolute case. Safe because all pre-S120 rows
                    // were absolute by definition (the percent code path didn't exist).
                    $wpdb->query(
                        "UPDATE {$table}
                         SET discount_value = price
                         WHERE discount_value = 0 AND price > 0"
                    );
                }
                update_option( 'titancart_migration_version', 33, false );
            }

            // Migration 34: Auto-create the Testimonials WP page (slug /testimonials/) with the
            // [titancart_testimonials] shortcode. Mirrors the migration 23 pattern. Idempotent:
            // if a page already contains the shortcode (any slug) or a page with slug
            // 'testimonials' already exists, we adopt that ID and skip creation.
            //
            // DEPRECATED CREATION PATH (S345 — Page Manager): now delegates to
            // PageRegistry::ensure( 'testimonials' ) — the single idempotent
            // adopt-or-create path (S343) — instead of a hand-rolled loop.
            // Retained (not deleted) as a version marker for historical installs;
            // new page creation belongs in PageRegistry, not here.
            if ( $migration_version < 34 ) {
                if ( class_exists( \TitanCart\Modules\Pages\PageRegistry::class ) ) {
                    $descriptor = \TitanCart\Modules\Pages\PageRegistry::get( 'testimonials' );
                    if ( $descriptor ) {
                        \TitanCart\Modules\Pages\PageRegistry::ensure( $descriptor );
                    }
                }

                update_option( 'titancart_migration_version', 34, false );
            }

            // Migration 35: Add activation_token column to tc_subscribers (S127.D — newsletter
            // double opt-in). Existing installs created the table at migration 17 without the
            // token column; new installs get it via Schema::subscribers(). Idempotent column
            // existence check (S122 pattern). Defaults to '' so existing rows are unaffected —
            // they remain in their current confirmed/unconfirmed state with no token, which means
            // pre-S127.D unconfirmed subscribers cannot be activated and need to re-subscribe.
            if ( $migration_version < 35 ) {
                global $wpdb;
                $table = $wpdb->prefix . 'tc_subscribers';
                $cols  = $wpdb->get_col( "DESCRIBE {$table}", 0 );
                if ( ! in_array( 'activation_token', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN activation_token VARCHAR(64) NOT NULL DEFAULT '' AFTER confirmed" );
                }
                update_option( 'titancart_migration_version', 35, false );
            }

            // Migration 36: Create tc_customer_field_values table (S127.C-1 — Profile Fields
            // rendering). System fields (first_name, last_name, email, phone, address_1, etc.)
            // are stored in tc_customers / tc_customer_addresses fixed columns; custom fields
            // admins create via the Customer Fields page (e.g. "Tax ID") had no values storage
            // until this migration. Without it, AccountManager::register and update_profile
            // could not persist custom field values — they would be silently dropped. Idempotent
            // existence check (S122 pattern). New installs get the table via
            // Schema::customer_field_values().
            if ( $migration_version < 36 ) {
                global $wpdb;
                $table   = $wpdb->prefix . 'tc_customer_field_values';
                $exists  = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( ! $exists ) {
                    $charset = $wpdb->get_charset_collate();
                    $wpdb->query( "CREATE TABLE {$table} (
                        id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        customer_id  BIGINT UNSIGNED NOT NULL,
                        field_id     BIGINT UNSIGNED NOT NULL,
                        value        TEXT            NOT NULL,
                        created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_customer_field (customer_id, field_id),
                        KEY idx_customer_id (customer_id),
                        KEY idx_field_id (field_id)
                    ) {$charset}" );
                }
                update_option( 'titancart_migration_version', 36, false );
            }

            // Migration 37: Downloadable Products — Phase 1 plumbing.
            //
            // Adds 5 new tables (downloadable_files, downloadable_keys, downloadable_log,
            // downloadable_agreements, downloadable_acceptances) for existing installs.
            // New installs get them from Schema::all() via dbDelta — same pattern as
            // Migration 36's customer_field_values.
            //
            // Also adds 2 nullable per-product override columns to tc_products:
            //   download_ttl_hours INT NULL   — overrides the global catalog_download_key_ttl
            //   download_max_count INT NULL   — caps downloads per buyer (NULL = unlimited)
            //
            // The dormant `is_downloadable` column already exists on tc_products and stays
            // as the canonical "this product is digital" flag (7 existing runtime callers
            // depend on it — see CartManager:245, CheckoutManager:249, etc.). The future
            // mixed-content case (downloadable + physical bundle) will gate on the existing
            // `requires_shipping` column, NOT on a new `edp_shipping` column. This is
            // intentional and aligned with TitanCart's lean-schema approach.
            //
            // No UI, no streaming endpoint, no email integration in this phase — the
            // tables and columns just become available. The DownloadableModule registers
            // hook listeners so keys get issued/revoked correctly even before the UI ships,
            // but customers see no visible change until Phase 5 flips `catalog_enable_downloads`.
            if ( $migration_version < 37 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();

                // ── 5 new tables ──────────────────────────────────────────────
                // Idempotent: each CREATE guarded by SHOW TABLES check (S122 pattern).
                $tables = [
                    'tc_downloadable_files' => "CREATE TABLE {$wpdb->prefix}tc_downloadable_files (
                        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        product_id      BIGINT UNSIGNED NOT NULL,
                        variation_id    BIGINT UNSIGNED NULL,
                        filename        VARCHAR(255)    NOT NULL DEFAULT '',
                        original_name   VARCHAR(255)    NOT NULL DEFAULT '',
                        file_path       VARCHAR(512)    NOT NULL DEFAULT '',
                        file_size       BIGINT UNSIGNED NOT NULL DEFAULT 0,
                        mime_type       VARCHAR(128)    NOT NULL DEFAULT '',
                        preview_path    VARCHAR(512)    NULL,
                        readme          TEXT            NULL,
                        position        INT UNSIGNED    NOT NULL DEFAULT 0,
                        status          ENUM('active','disabled') NOT NULL DEFAULT 'active',
                        created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_product_id   (product_id),
                        KEY idx_variation_id (variation_id),
                        KEY idx_status       (status)
                    ) {$charset}",

                    'tc_downloadable_keys' => "CREATE TABLE {$wpdb->prefix}tc_downloadable_keys (
                        id                 BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        key_hash           CHAR(64)        NOT NULL,
                        order_id           BIGINT UNSIGNED NOT NULL,
                        order_item_id      BIGINT UNSIGNED NOT NULL,
                        product_id         BIGINT UNSIGNED NOT NULL,
                        file_id            BIGINT UNSIGNED NOT NULL,
                        customer_id        BIGINT UNSIGNED NULL,
                        email              VARCHAR(255)    NOT NULL DEFAULT '',
                        status             ENUM('active','expired','revoked') NOT NULL DEFAULT 'active',
                        expires_at         DATETIME        NULL,
                        download_count     INT UNSIGNED    NOT NULL DEFAULT 0,
                        max_downloads      INT UNSIGNED    NULL,
                        last_ip            VARCHAR(45)     NULL,
                        last_downloaded_at DATETIME        NULL,
                        created_at         DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        UNIQUE KEY uq_key_hash (key_hash),
                        KEY idx_order_id      (order_id),
                        KEY idx_order_item_id (order_item_id),
                        KEY idx_customer_id   (customer_id),
                        KEY idx_email         (email),
                        KEY idx_status        (status),
                        KEY idx_expires_at    (expires_at)
                    ) {$charset}",

                    'tc_downloadable_log' => "CREATE TABLE {$wpdb->prefix}tc_downloadable_log (
                        id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        key_id        BIGINT UNSIGNED NULL,
                        event         ENUM('issued','downloaded','expired','revoked','failed_auth','failed_quota') NOT NULL DEFAULT 'downloaded',
                        ip_address    VARCHAR(45)     NULL,
                        user_agent    VARCHAR(255)    NULL,
                        bytes_served  BIGINT UNSIGNED NOT NULL DEFAULT 0,
                        meta          JSON            NULL,
                        created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_key_id     (key_id),
                        KEY idx_event      (event),
                        KEY idx_created_at (created_at)
                    ) {$charset}",

                    'tc_downloadable_agreements' => "CREATE TABLE {$wpdb->prefix}tc_downloadable_agreements (
                        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        product_id      BIGINT UNSIGNED NULL,
                        is_global       TINYINT(1)      NOT NULL DEFAULT 0,
                        version         INT UNSIGNED    NOT NULL DEFAULT 1,
                        title           VARCHAR(255)    NOT NULL DEFAULT '',
                        content         LONGTEXT        NOT NULL,
                        language_code   CHAR(2)         NOT NULL DEFAULT 'en',
                        required        TINYINT(1)      NOT NULL DEFAULT 1,
                        status          ENUM('active','archived') NOT NULL DEFAULT 'active',
                        created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        updated_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_product_id    (product_id),
                        KEY idx_is_global     (is_global),
                        KEY idx_status        (status),
                        KEY idx_language_code (language_code)
                    ) {$charset}",

                    'tc_downloadable_acceptances' => "CREATE TABLE {$wpdb->prefix}tc_downloadable_acceptances (
                        id                     BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        agreement_id           BIGINT UNSIGNED NOT NULL,
                        agreement_version      INT UNSIGNED    NOT NULL DEFAULT 1,
                        customer_id            BIGINT UNSIGNED NULL,
                        email                  VARCHAR(255)    NOT NULL DEFAULT '',
                        order_id               BIGINT UNSIGNED NULL,
                        order_item_id          BIGINT UNSIGNED NULL,
                        ip_address             VARCHAR(45)     NULL,
                        user_agent             VARCHAR(255)    NULL,
                        accepted_content_hash  CHAR(64)        NOT NULL,
                        accepted_at            DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_agreement_id  (agreement_id),
                        KEY idx_customer_id   (customer_id),
                        KEY idx_email         (email),
                        KEY idx_order_id      (order_id),
                        KEY idx_order_item_id (order_item_id),
                        KEY idx_accepted_at   (accepted_at)
                    ) {$charset}",
                ];

                foreach ( $tables as $table_name => $sql ) {
                    $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
                    if ( ! $exists ) {
                        $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is one of the literal CREATE TABLE statements defined in the $tables array above (migration DDL; only interpolation is $wpdb->prefix + charset_collate).
                    }
                }

                // ── 2 new product columns ─────────────────────────────────────
                // Per-product override of the global catalog_download_key_ttl setting
                // and per-product cap on download count. NULL = use global / unlimited.
                $products_table  = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'products';
                $products_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $products_table ) );
                if ( $products_exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$products_table} LIKE 'download_ttl_hours'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$products_table} ADD COLUMN download_ttl_hours INT NULL AFTER is_downloadable" );
                    }
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$products_table} LIKE 'download_max_count'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$products_table} ADD COLUMN download_max_count INT NULL AFTER download_ttl_hours" );
                    }
                }

                update_option( 'titancart_migration_version', 37, false );
            }

            // Migration 38: Strip redundant country=X rows from tax zones that
            // also have a state=X:* row. The state-zone seeder used to add both
            // a country row AND a state row to the same zone, causing the
            // matcher to classify any address in that country as a country-level
            // hit on what was meant to be a state-only zone (e.g. NY → PA rate).
            // This migration cleans up zones created by the buggy seeder.
            if ( $migration_version < 38 ) {
                global $wpdb;
                $locs_table = $wpdb->prefix . 'tc_tax_zone_locations';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $locs_table ) );
                if ( $exists ) {
                    // For every country row, check if the same zone has a
                    // state row whose code starts with "{country}:". If so,
                    // the country row is redundant and was added in error.
                    $rows = $wpdb->get_results(
                        "SELECT id, zone_id, code FROM {$locs_table} WHERE type = 'country'",
                        ARRAY_A
                    );
                    if ( $rows ) {
                        $to_delete = [];
                        foreach ( $rows as $row ) {
                            $zone_id      = (int) $row['zone_id'];
                            $country_code = strtoupper( (string) $row['code'] );
                            if ( $country_code === '' ) {
                                continue;
                            }
                            $state_prefix = $country_code . ':';
                            // Use LIKE to find any matching state row in the same zone.
                            $has_state = $wpdb->get_var( $wpdb->prepare(
                                "SELECT id FROM {$locs_table}
                                 WHERE zone_id = %d AND type = 'state' AND code LIKE %s
                                 LIMIT 1",
                                $zone_id,
                                $wpdb->esc_like( $state_prefix ) . '%'
                            ) );
                            if ( $has_state ) {
                                $to_delete[] = (int) $row['id'];
                            }
                        }
                        if ( $to_delete ) {
                            // WP.org R2 §8: generated %d placeholders, ids bound via prepare().
                            $ids_ph = implode( ',', array_fill( 0, count( $to_delete ), '%d' ) );
                            $wpdb->query( $wpdb->prepare( "DELETE FROM {$locs_table} WHERE id IN ({$ids_ph})", $to_delete ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $locs_table = prefix . literal; $ids_ph is a generated %d list.
                        }
                    }
                }

                update_option( 'titancart_migration_version', 38, false );
            }

            // Migration 39: Add order_key column to tc_orders + backfill existing rows.
            // Stops public order lookup from being a PII leak — order_number is
            // predictable (TC-YYYYMMDD-NNNN), so anyone can enumerate them. The
            // order_key is a non-enumerable 32-char hex string required to view
            // the order on the thank-you page or via REST GET /orders/{number}
            // unless the caller is an admin or the authenticated owner.
            if ( $migration_version < 39 ) {
                global $wpdb;
                $orders_table = $wpdb->prefix . 'tc_orders';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $orders_table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$orders_table} LIKE 'order_key'" );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$orders_table}
                             ADD COLUMN order_key VARCHAR(64) NOT NULL DEFAULT '' AFTER order_number"
                        );
                    }
                    // Backfill any rows that lack a key. random_bytes is CSPRNG.
                    $rows = $wpdb->get_col(
                        "SELECT id FROM {$orders_table} WHERE order_key = '' OR order_key IS NULL"
                    );
                    foreach ( $rows as $rid ) {
                        try {
                            $key = bin2hex( random_bytes( 16 ) );
                        } catch ( \Throwable $e ) {
                            // random_bytes throws if the system can't supply entropy.
                            // Fall back to wp_generate_password (which itself uses
                            // random_int internally on modern WP) — last-ditch only.
                            $key = wp_generate_password( 32, false, false );
                        }
                        $wpdb->update(
                            $orders_table,
                            [ 'order_key' => $key ],
                            [ 'id' => (int) $rid ],
                            [ '%s' ],
                            [ '%d' ]
                        );
                    }
                }

                update_option( 'titancart_migration_version', 39, false );
            }

            // Migration 40: S139 — Clean up orphan tc_settings rows from the
            // decommissioned Maps & Geolocation extension. The extension was
            // a placeholder scaffold (registry entry + schema_maps method)
            // that never had a runtime Module/ implementation; S139 removed
            // both the registry entry and the schema method, consolidating
            // the address-autocomplete Google API key under Settings →
            // Checkout → Address Settings instead.
            //
            // Without this migration, any pre-S139 install that toggled the
            // Maps extension active or saved any of its settings would carry
            // forever-orphaned rows in tc_settings — `section='maps'` rows
            // for the schema fields (provider, google_api_key, etc.) plus
            // the `section='extensions', key='maps'` activation row.
            //
            // Idempotent: DELETE on already-missing rows is a no-op, so this
            // is safe to re-run and safe on fresh installs that never had
            // the extension touched.
            if ( $migration_version < 40 ) {
                global $wpdb;
                $settings_table = $wpdb->prefix . 'tc_settings';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $settings_table ) );
                if ( $exists ) {
                    // Drop all rows owned by the decommissioned extension's
                    // settings section.
                    $wpdb->query(
                        $wpdb->prepare(
                            "DELETE FROM {$settings_table} WHERE section = %s",
                            'maps'
                        )
                    );
                    // Drop the extension activation row in the registry.
                    $wpdb->query(
                        $wpdb->prepare(
                            "DELETE FROM {$settings_table} WHERE section = %s AND `key` = %s",
                            'extensions',
                            'maps'
                        )
                    );
                }

                update_option( 'titancart_migration_version', 40, false );
            }

            // Migration 41: S140 — Sequential Options TitanCart-native.
            //
            // Adds 1 new table (tc_product_option_dependencies) for the
            // dependency-edge graph that powers grey-until-unlocked options,
            // plus 1 new column (compound_pricing) on tc_product_options for
            // opt-in compounding-percent-modifier semantics.
            //
            // Per-edge dependency rows say "option_id is unlocked when the
            // customer has selected parent_variant_id on parent_option_id."
            // OR semantics within same parent_option_id, AND across different
            // parent_option_ids. No row for an option_id at all = flat option
            // (always visible) — preserves zero behavior change for every
            // existing product on the install.
            //
            // compound_pricing: when 1, this option's selected variant's
            // percent modifier applies to the running effective_price (post-
            // prior-modifiers in DB position order) instead of the base
            // unit_price. Default 0 = current additive behavior. Most
            // relevant for sequential-options chains; technically usable on
            // any option.
            //
            // Cycle prevention is enforced at REST-save time via DFS on the
            // candidate edge set (see ProductsController). No runtime cycle
            // check needed because the unlock evaluator only ever traverses
            // FROM dependent TOWARD parents (one direction), and saved data
            // is guaranteed acyclic.
            //
            // Idempotent: SHOW TABLES / SHOW COLUMNS guards on every step.
            // Safe to re-run; safe on fresh installs (Schema::all() also
            // includes the new table + column for dbDelta path).
            if ( $migration_version < 41 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();

                // ── New table ─────────────────────────────────────────────
                $deps_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_option_dependencies';
                $deps_exists = $wpdb->get_var(
                    $wpdb->prepare( 'SHOW TABLES LIKE %s', $deps_table )
                );
                if ( ! $deps_exists ) {
                    $wpdb->query(
                        "CREATE TABLE {$deps_table} (
                            id                BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                            option_id         BIGINT UNSIGNED NOT NULL,
                            parent_option_id  BIGINT UNSIGNED NOT NULL,
                            parent_variant_id BIGINT UNSIGNED NOT NULL,
                            created_at        DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
                            PRIMARY KEY (id),
                            UNIQUE KEY uq_dep_edge (option_id, parent_option_id, parent_variant_id),
                            KEY idx_option_id (option_id),
                            KEY idx_parent (parent_option_id, parent_variant_id)
                        ) {$charset}"
                    );
                }

                // ── New column on tc_product_options ──────────────────────
                $opts_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_options';
                $opts_exists = $wpdb->get_var(
                    $wpdb->prepare( 'SHOW TABLES LIKE %s', $opts_table )
                );
                if ( $opts_exists ) {
                    $col = $wpdb->get_row(
                        "SHOW COLUMNS FROM {$opts_table} LIKE 'compound_pricing'"
                    );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$opts_table}
                             ADD COLUMN compound_pricing TINYINT(1) NOT NULL DEFAULT 0
                             AFTER required"
                        );
                    }
                }

                update_option( 'titancart_migration_version', 41, false );
            }

            // Migration 42: S190 — Track B Module B4 order splitting foundation.
            //
            // Adds 1 nullable column + 1 index to tc_order_items for per-line
            // immutable vendor provenance:
            //
            //   vendor_id    BIGINT UNSIGNED  NULL    -- tc_vendors.id stamp
            //   KEY idx_vendor_id (vendor_id)
            //
            // Path B1 (locked at S190 open after 8+ disk surveys + Stripe
            // Connect Express alignment analysis):
            //
            //   One tc_orders row per checkout, stamp the vendor on each
            //   tc_order_items row at write time via the new
            //   `titancart_order_item_data` filter hook in OrderRepository.
            //   The marketplace's listener (VendorOrderItemStamper, S191)
            //   resolves vendor_id from tc_vendor_products active-only by
            //   product_id and writes the stamp. NULL when no vendor exists
            //   for the product (admin-only product, or marketplace inactive).
            //
            // Why on tc_order_items (TC core) vs a marketplace-side junction:
            //   - Per-line money grouping (B5 commission, B6 Stripe destination
            //     transfer_data) is a direct GROUP BY vendor_id on this table
            //     with the index doing the work. Junction-based design forces
            //     a 3-table JOIN on every settlement query.
            //   - Immutable provenance: stamping at write time captures the
            //     vendor as-of-purchase even when junctions later change
            //     (reassignment, soft-delete, hard-delete). The S187 read
            //     path uses live-junction EXISTS by design ("orders for
            //     products you CURRENTLY own"); money flow needs the
            //     opposite ("vendor at sale time"). Both can coexist.
            //   - NULL when marketplace is inactive — TC core remains
            //     vendor-unaware functionally; the column is reserved space
            //     until an extension populates it via the filter hook.
            //     Precedent: Migration 21 reserved age_restricted +
            //     age_limit on tc_products for the Age Verification
            //     extension.
            //
            // Idempotent: SHOW COLUMNS / SHOW INDEX guards on every step.
            // Safe to re-run; safe on fresh installs (Schema.php order_items()
            // definition matches this exactly, so dbDelta path on first
            // activation produces an identical end state).
            //
            // Backfill: NONE. Existing tc_order_items rows keep vendor_id
            // NULL. Retroactive backfill from tc_vendor_products would lie
            // about provenance for any product that changed hands (or had
            // its junction edited) between the order date and now. The
            // "vendor at sale time" stamp is only meaningful from this
            // migration forward — pre-B4 orders are explicitly opaque to
            // vendor revenue queries. B5 commission math will surface this
            // boundary clearly (vendor sees "earnings since YYYY-MM-DD").
            //
            // WooImport interaction: WooImportService writes tc_order_items
            // rows directly via $wpdb->insert without going through the new
            // filter. Imported orders will have vendor_id NULL — correct
            // semantic, those orders predate marketplace activation by
            // definition.
            if ( $migration_version < 42 ) {
                global $wpdb;
                $oi_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'order_items';
                $oi_exists = $wpdb->get_var(
                    $wpdb->prepare( 'SHOW TABLES LIKE %s', $oi_table )
                );
                if ( $oi_exists ) {
                    // ── Column ────────────────────────────────────────────
                    $col = $wpdb->get_row(
                        "SHOW COLUMNS FROM {$oi_table} LIKE 'vendor_id'"
                    );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$oi_table}
                             ADD COLUMN vendor_id BIGINT UNSIGNED NULL
                             AFTER product_id"
                        );
                    }

                    // ── Index ─────────────────────────────────────────────
                    // SHOW INDEX returns rows keyed by Key_name. Filter to
                    // see whether idx_vendor_id already exists before
                    // attempting to add it (ALTER ADD INDEX on an existing
                    // key name throws a "Duplicate key name" error).
                    $idx = $wpdb->get_row(
                        "SHOW INDEX FROM {$oi_table} WHERE Key_name = 'idx_vendor_id'"
                    );
                    if ( ! $idx ) {
                        $wpdb->query(
                            "ALTER TABLE {$oi_table}
                             ADD INDEX idx_vendor_id (vendor_id)"
                        );
                    }
                }

                update_option( 'titancart_migration_version', 42, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 43 — Composite vendor reviews (S201, Track B Module B9)
            // ─────────────────────────────────────────────────────────────────
            // Adds vendor_id + vendor_rating columns + idx_vendor_id to
            // tc_reviews. Enables the marketplace extension to capture an
            // optional 1-5 star vendor rating alongside the existing product
            // rating, with a single review row per product purchase
            // (Etsy/eBay model). vendor_id is populated at INSERT time by the
            // marketplace's VendorReviewDataStamper listener subscribing to
            // the new `titancart_review_data` filter (Pattern #158 ninth
            // instance). vendor_rating is NULL when:
            //   - product has no vendor (admin product)
            //   - customer chose not to rate the vendor
            //
            // Vendor aggregate (review_avg + review_count) is denormalized
            // onto tc_vendors and maintained by VendorReviewAggregator
            // (Pattern #166) on review create/moderate/delete.
            //
            // Backfill: NONE. Existing tc_reviews rows keep vendor_id +
            // vendor_rating NULL. Retroactive backfill from current
            // tc_vendor_products junction would lie about provenance for any
            // product that changed hands or had its vendor assignment edited
            // between the review date and now. The "vendor at review time"
            // stamp is only meaningful from this migration forward —
            // pre-B9 reviews are explicitly opaque to vendor reputation
            // queries. First post-migration review for each vendor seeds
            // their aggregate via the listener path.
            if ( $migration_version < 43 ) {
                global $wpdb;
                $rv_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'reviews';
                $rv_exists = $wpdb->get_var(
                    $wpdb->prepare( 'SHOW TABLES LIKE %s', $rv_table )
                );
                if ( $rv_exists ) {
                    // ── Column: vendor_id ─────────────────────────────────
                    $col = $wpdb->get_row(
                        "SHOW COLUMNS FROM {$rv_table} LIKE 'vendor_id'"
                    );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$rv_table}
                             ADD COLUMN vendor_id BIGINT UNSIGNED NULL
                             AFTER product_id"
                        );
                    }

                    // ── Column: vendor_rating ─────────────────────────────
                    $col = $wpdb->get_row(
                        "SHOW COLUMNS FROM {$rv_table} LIKE 'vendor_rating'"
                    );
                    if ( ! $col ) {
                        $wpdb->query(
                            "ALTER TABLE {$rv_table}
                             ADD COLUMN vendor_rating TINYINT UNSIGNED NULL
                             AFTER rating"
                        );
                    }

                    // ── Index: idx_vendor_id ──────────────────────────────
                    // SHOW INDEX returns rows keyed by Key_name. Filter to
                    // see whether idx_vendor_id already exists before
                    // attempting to add it (ALTER ADD INDEX on an existing
                    // key name throws a "Duplicate key name" error).
                    $idx = $wpdb->get_row(
                        "SHOW INDEX FROM {$rv_table} WHERE Key_name = 'idx_vendor_id'"
                    );
                    if ( ! $idx ) {
                        $wpdb->query(
                            "ALTER TABLE {$rv_table}
                             ADD INDEX idx_vendor_id (vendor_id)"
                        );
                    }
                }

                update_option( 'titancart_migration_version', 43, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 44 — Age Verification: category-level restriction (S238)
            // ─────────────────────────────────────────────────────────────────
            // Adds age_restricted + age_limit columns to tc_categories, mirroring
            // Migration 21's product-level additions. AgeVerificationExtension::
            // product_age_requirement() then takes MAX of the product's own limit
            // and any of its categories' limits ("strictest age wins"). Idempotent
            // via the DESCRIBE-then-ALTER pattern; safe to re-run.
            if ( $migration_version < 44 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'categories';
                $cols  = $wpdb->get_col( "DESCRIBE {$table}", 0 );
                if ( ! in_array( 'age_restricted', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN age_restricted TINYINT(1) NOT NULL DEFAULT 0 AFTER status" );
                }
                if ( ! in_array( 'age_limit', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN age_limit SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER age_restricted" );
                }
                update_option( 'titancart_migration_version', 44, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 45 — Newsletter per-recipient send log (Phase 5 / S239)
            // ─────────────────────────────────────────────────────────────────
            // Adds tc_newsletter_sends — the persistent queue + send log that
            // NewsletterDispatcher drains via WP-Cron. One row per recipient
            // per campaign. Drives admin progress polling AND cross-campaign
            // suppression (bounces on any prior newsletter skip the address
            // on future sends).
            //
            // Mirrors Schema::newsletter_sends() exactly — kept in sync with
            // fresh-install dbDelta path. Idempotent SHOW TABLES guard. Safe
            // to re-run.
            if ( $migration_version < 45 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $sends_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'newsletter_sends';
                $exists = $wpdb->get_var(
                    $wpdb->prepare( 'SHOW TABLES LIKE %s', $sends_table )
                );
                if ( ! $exists ) {
                    $wpdb->query(
                        "CREATE TABLE {$sends_table} (
                            id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                            newsletter_id  BIGINT UNSIGNED NOT NULL,
                            subscriber_id  BIGINT UNSIGNED NOT NULL DEFAULT 0,
                            email          VARCHAR(190) NOT NULL,
                            status         ENUM('queued','sent','failed','bounced','skipped') NOT NULL DEFAULT 'queued',
                            attempts       TINYINT UNSIGNED NOT NULL DEFAULT 0,
                            error          VARCHAR(512) NOT NULL DEFAULT '',
                            sent_at        DATETIME DEFAULT NULL,
                            created_at     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                            updated_at     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                            PRIMARY KEY (id),
                            KEY idx_newsletter_status (newsletter_id, status),
                            KEY idx_email (email),
                            KEY idx_queued (status, id)
                        ) {$charset}"
                    );
                }
                update_option( 'titancart_migration_version', 45, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 46 — Newsletter mailer UX rework (Phase 5 / S239)
            // ─────────────────────────────────────────────────────────────────
            // The Newsletter Mailing Server settings panel previously offered
            // a 3-option `mailer_send_method` select (PHP mail / SMTP / wp_mail)
            // that no production code actually consumed — operator-confusing
            // dead UI. Phase 5 replaces it with a single clear boolean:
            //
            //   mailer_use_override  TINYINT-as-string '0' | '1'
            //     '0' = newsletter sends through wp_mail() — inherits the
            //           General → Emails SMTP config via EmailTransport's
            //           phpmailer_init@10 hook (right answer for 90% of stores)
            //     '1' = newsletter uses the mailer_smtp_* fields below as an
            //           override — NewsletterMailer hooks phpmailer_init@20
            //           for the duration of a newsletter send, overriding
            //           EmailTransport's defaults
            //
            // Migration: any existing row with `mailer_send_method='smtp'`
            // means the operator deliberately picked SMTP override before;
            // map that to `mailer_use_override='1'`. Anything else (or
            // missing) becomes '0' (the safe default). Then DELETE the old
            // `mailer_send_method` row so it can never resurface in the UI.
            //
            // Idempotent: re-running just re-applies the same map (the
            // mailer_send_method row will be gone after the first run, so
            // step 1 silently returns NULL).
            if ( $migration_version < 46 ) {
                global $wpdb;
                $settings_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'settings';

                // Step 1: read the legacy send_method value (if any)
                $legacy_method = $wpdb->get_var( $wpdb->prepare(
                    "SELECT value FROM {$settings_table} WHERE section = %s AND `key` = %s LIMIT 1",
                    'newsletters', 'mailer_send_method'
                ) );

                $override_value = ( $legacy_method === 'smtp' ) ? '1' : '0';

                // Step 2: upsert the new mailer_use_override key
                $existing_override = $wpdb->get_var( $wpdb->prepare(
                    "SELECT id FROM {$settings_table} WHERE section = %s AND `key` = %s LIMIT 1",
                    'newsletters', 'mailer_use_override'
                ) );
                if ( ! $existing_override ) {
                    $wpdb->insert( $settings_table, [
                        'section'  => 'newsletters',
                        'key'      => 'mailer_use_override',
                        'value'    => $override_value,
                        'autoload' => 1,
                    ] );
                }

                // Step 3: remove the legacy mailer_send_method row entirely
                if ( $legacy_method !== null ) {
                    $wpdb->delete( $settings_table, [
                        'section' => 'newsletters',
                        'key'     => 'mailer_send_method',
                    ] );
                }

                update_option( 'titancart_migration_version', 46, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 47 — GDPR deferred erasure schedule (S246)
            // ─────────────────────────────────────────────────────────────────
            // Adds tc_customers.erasure_scheduled_at. When a customer requests
            // account deletion (storefront My Account), GdprExtension wipes the
            // non-order PII immediately but HOLDS the order-snapshot PII
            // (billing/shipping name, email, phone, address on tc_orders) until
            // last_order_date + the GDPR "data retention" window (chargeback +
            // legal-claims hold, GDPR Art.17(3)). This column stamps the due
            // date; the daily `titancart_gdpr_erasure_tick` cron anonymizes the order
            // PII once it passes, then clears the stamp. Idempotent
            // DESCRIBE-then-ALTER; safe to re-run.
            if ( $migration_version < 47 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'customers';
                $cols  = $wpdb->get_col( "DESCRIBE {$table}", 0 );
                if ( ! in_array( 'erasure_scheduled_at', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN erasure_scheduled_at DATETIME NULL AFTER deleted_at" );
                    $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_erasure_scheduled (erasure_scheduled_at)" );
                }
                update_option( 'titancart_migration_version', 47, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 48 — Gift card redemption at checkout (S255)
            // ─────────────────────────────────────────────────────────────────
            // Adds gift_card_code + gift_card_discount to tc_orders so the
            // applied gift card is recorded on the order row itself. The
            // redemption is FINALIZED on titancart_order_committed (genuine paid
            // commit), and the async/3DS commit dispatch passes an empty $totals
            // — so the balance draw-down must read the code + amount off the
            // persisted order (by order_id), NOT off the event payload. These
            // columns are that persistence (mirrors coupon_code/coupon_discount).
            // gift_card_discount is the amount applied as payment; the order
            // `total` already reflects it. Idempotent DESCRIBE-then-ALTER; safe
            // to re-run; Schema::orders() carries the same columns for the fresh
            // install dbDelta path.
            if ( $migration_version < 48 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'orders';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'gift_card_code'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN gift_card_code VARCHAR(64) NULL AFTER coupon_discount" );
                    }
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'gift_card_discount'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN gift_card_discount DECIMAL(12,4) NOT NULL DEFAULT 0.0000 AFTER gift_card_code" );
                    }
                }
                update_option( 'titancart_migration_version', 48, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 49 — Reward points recorded on the order row (S255, KI-04)
            // ─────────────────────────────────────────────────────────────────
            // Adds points_redeemed + points_discount to tc_orders so reward-point
            // redemption can be FINALIZED on titancart_order_committed (genuine
            // paid commit) instead of titancart_order_placed (the KI-04 trap —
            // points were awarded/deducted on a declined order). The async/3DS
            // commit dispatch passes an EMPTY $totals, so the committed handler
            // reads these back off the persisted order row by id; they are stamped
            // here at order-creation time, which happens on BOTH commit paths.
            // Idempotent (SHOW COLUMNS guard); Schema::orders() carries the same
            // columns for the fresh-install dbDelta path.
            if ( $migration_version < 49 ) {
                global $wpdb;
                $table  = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'orders';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'points_redeemed'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN points_redeemed INT UNSIGNED NOT NULL DEFAULT 0 AFTER gift_card_discount" );
                    }
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'points_discount'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN points_discount DECIMAL(12,4) NOT NULL DEFAULT 0.0000 AFTER points_redeemed" );
                    }
                }
                update_option( 'titancart_migration_version', 49, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 50 — gift_card_log 'emailed' action (S255, KI-10)
            // ─────────────────────────────────────────────────────────────────
            // GiftCardManager::send_email() logs action='emailed', which was not a
            // member of the tc_gift_card_log.action ENUM (coerced to '' on a
            // non-strict MySQL, would error under strict mode). dbDelta CANNOT
            // modify an ENUM, so MODIFY COLUMN explicitly. Idempotent: re-running
            // the same MODIFY is a no-op. Schema::gift_card_log() carries the full
            // ENUM for the fresh-install path.
            if ( $migration_version < 50 ) {
                global $wpdb;
                $table  = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'gift_card_log';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $wpdb->query( "ALTER TABLE {$table} MODIFY COLUMN action ENUM('created','redeemed','refunded','adjusted','disabled','emailed') NOT NULL" );
                }
                update_option( 'titancart_migration_version', 50, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 51 — Newsletters self-service unsubscribe (S263, KI-14 / roadmap #6)
            // ─────────────────────────────────────────────────────────────────
            // Adds a stable per-subscriber unsubscribe_token to tc_subscribers so
            // the public tokenized GET/POST /subscribe/unsubscribe endpoint can
            // flip status to 'unsubscribed' (after which NewsletterDispatcher::
            // is_suppressed() suppresses the address from all future sends,
            // cross-campaign). Drives the %UNSUBSCRIBE_LINK merge tag + the
            // List-Unsubscribe / List-Unsubscribe-Post one-click header. Unlike
            // activation_token this is NOT cleared on use — the link and the
            // mailbox-provider header must keep working across many campaigns.
            // create_subscriber() mints one for every new row; pre-migration rows
            // are lazily backfilled by get_or_create_unsubscribe_token() at send
            // time. dbDelta CANNOT add a column to an existing table reliably, so
            // ALTER explicitly; idempotent via SHOW COLUMNS. Schema::subscribers()
            // carries the column for the fresh-install path.
            if ( $migration_version < 51 ) {
                global $wpdb;
                $table  = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'subscribers';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'unsubscribe_token'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$table} ADD COLUMN unsubscribe_token VARCHAR(64) NOT NULL DEFAULT '' AFTER activation_token" );
                    }
                }
                update_option( 'titancart_migration_version', 51, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 52 — order stock_state marker + retire vestigial 'reduce' (S266, KI-16)
            // ─────────────────────────────────────────────────────────────────
            // KI-16: the per-status stock_action control was a phantom — written,
            // seeded, REST-validated and badged, but no runtime consumer. TitanCart
            // decrements stock ONCE at payment commit (CheckoutManager) and restores
            // only via RMA; it does NOT use the status-driven inventory model the
            // seeded defaults implied. S266 wires the RESTORE direction only: a new
            // restore-only listener returns committed stock when an order enters a
            // stock_action='restore' status (e.g. cancelled/refunded), which today
            // never happens (committed stock stays gone unless an RMA is filed).
            //
            // stock_state is the durable per-order guard the listener needs:
            //   'none'     — never decremented (unpaid/pending/failed orders)
            //   'reduced'  — stock taken (stamped by the commit decrement)
            //   'restored' — stock returned (stamped by the restore listener)
            // It survives the deferred (Stripe Card Elements / 3DS) commit, where
            // the 1h titancart_order_committed_{id} transient can't gate a status change
            // days later. Same rationale as Migration 49's points_redeemed columns.
            // Schema::orders() carries the column for the fresh-install path.
            //
            // The 'reduce' DIRECTION is already owned by the commit decrement, so
            // wiring it on the status listener would double-count. We retire the
            // vestigial seeded processing='reduce' (and any admin-set 'reduce') to
            // 'none' here: nothing ever read it, so this is behaviorally inert and
            // purely de-phantoms the editor. The ENUM keeps 'reduce' as harmless
            // foundation (no ENUM ALTER — dbDelta can't, and it isn't needed).
            if ( $migration_version < 52 ) {
                global $wpdb;
                $orders = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'orders';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $orders ) );
                if ( $exists ) {
                    $col = $wpdb->get_row( "SHOW COLUMNS FROM {$orders} LIKE 'stock_state'" );
                    if ( ! $col ) {
                        $wpdb->query( "ALTER TABLE {$orders} ADD COLUMN stock_state ENUM('none','reduced','restored') NOT NULL DEFAULT 'none' AFTER points_discount" );
                    }
                }
                $statuses = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'order_statuses';
                $st_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $statuses ) );
                if ( $st_exists ) {
                    $wpdb->query( "UPDATE {$statuses} SET stock_action = 'none' WHERE stock_action = 'reduce'" );
                }
                update_option( 'titancart_migration_version', 52, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 53 — reward_point_log dedupe key + de-phantom order_placed rule (S275, KI-13)
            // ─────────────────────────────────────────────────────────────────
            // #5 / KI-13: the event-driven earning engine awards bonus points on
            // discrete, order-less events (account_created, review_posted). The log
            // had no per-event idempotency key — order events dedupe on
            // (order_id, action), but a signup/review bonus has no order_id, so a
            // re-fired registration hook or a re-moderated review could double-pay.
            //
            //   event      — which earning event minted the row (NULL for legacy /
            //                manual /adjust / order_placed earn rows; those keep
            //                their existing (order_id, action) idempotency).
            //   dedup_key  — atomic one-time guard. UNIQUE so a duplicate award is
            //                rejected at the DB even under a race. Keys are scoped
            //                per event+subject: signup:{cid}, review:{review_id},
            //                birthday:{cid}:{year}. MySQL allows many NULLs in a
            //                UNIQUE index, so all legacy/order rows (dedup_key NULL)
            //                coexist untouched.
            // Schema::reward_point_log() carries both for the fresh-install path.
            //
            // De-phantom: under Option A (S275) the rules table owns ONLY the bonus
            // events; per-dollar order earning stays on the group-keyed Earning
            // Rates tab (get_earn_rate_for_customer / on_order_committed). The
            // Migration-14 seed planted an `order_placed` rule that nothing ever
            // read (phantom since inception). Delete it so the re-enabled "Bonus
            // Rules" tab can't surface a rule that never fires. Same spirit as
            // Migration 52 retiring the vestigial stock_action='reduce'.
            if ( $migration_version < 53 ) {
                global $wpdb;
                $log    = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'reward_point_log';
                $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $log ) );
                if ( $exists ) {
                    if ( ! $wpdb->get_row( "SHOW COLUMNS FROM {$log} LIKE 'event'" ) ) {
                        $wpdb->query( "ALTER TABLE {$log} ADD COLUMN event VARCHAR(64) NULL AFTER reason" );
                    }
                    if ( ! $wpdb->get_row( "SHOW COLUMNS FROM {$log} LIKE 'dedup_key'" ) ) {
                        $wpdb->query( "ALTER TABLE {$log} ADD COLUMN dedup_key VARCHAR(191) NULL AFTER event" );
                    }
                    // SHOW INDEX returns one row per index; guard so the UNIQUE key
                    // is added only once (re-running ADD UNIQUE would error).
                    $has_idx = $wpdb->get_row( "SHOW INDEX FROM {$log} WHERE Key_name = 'uq_dedup_key'" );
                    if ( ! $has_idx ) {
                        $wpdb->query( "ALTER TABLE {$log} ADD UNIQUE KEY uq_dedup_key (dedup_key)" );
                    }
                }
                $rules    = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'reward_point_rules';
                $r_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $rules ) );
                if ( $r_exists ) {
                    $wpdb->query( "DELETE FROM {$rules} WHERE event = 'order_placed'" );
                }
                update_option( 'titancart_migration_version', 53, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 54 — tc_customers.date_of_birth (S276, #5 / KI-13 birthday)
            // ─────────────────────────────────────────────────────────────────
            // #5 / KI-13: the `birthday` bonus event needs a per-customer date of
            // birth to fire against. The Age Verification extension's DOB prompt is
            // an ephemeral cookie-based age gate that never persists to the customer
            // record, so there was no stored DOB anywhere. This adds the canonical
            // column the storefront profile editor, the admin customer editor, and
            // the daily birthday cron all read/write.
            //
            //   date_of_birth — DATE NULL. NULL = customer hasn't supplied one
            //                  (the cron simply skips them). DATE (not DATETIME):
            //                  a birthday is a calendar day, no time component; the
            //                  cron matches on MONTH()/DAY() = today.
            // Schema::customers() carries the column for the fresh-install path.
            if ( $migration_version < 54 ) {
                global $wpdb;
                $customers = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'customers';
                $exists    = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $customers ) );
                if ( $exists ) {
                    if ( ! $wpdb->get_row( "SHOW COLUMNS FROM {$customers} LIKE 'date_of_birth'" ) ) {
                        $wpdb->query( "ALTER TABLE {$customers} ADD COLUMN date_of_birth DATE NULL AFTER phone" );
                    }
                }
                update_option( 'titancart_migration_version', 54, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 55 — tc_customers referral columns (S277, #5 / KI-13 referral)
            // ─────────────────────────────────────────────────────────────────
            // #5 / KI-13: the `referral` bonus event (the last #5 event) needs
            // per-customer referral attribution. Two canonical 1:1 columns on
            // tc_customers (not a side table — the payout/audit record already lives
            // in tc_reward_point_log via dedup_key referral:{referee}). Core owns
            // identity/attribution; the rewards extension owns the payout.
            //
            //   referral_code — VARCHAR(32) NULL UNIQUE. The customer's own shareable
            //                   code (minted at registration; lazy-backfilled for
            //                   existing customers on first access). UNIQUE allows many
            //                   NULLs in MySQL so un-minted rows coexist.
            //   referred_by   — BIGINT UNSIGNED NULL. tc_customers.id of whoever
            //                   referred this customer (resolved from the code entered
            //                   at registration). NULL = not referred. Write-once.
            // Schema::customers() carries both columns + indexes for fresh installs.
            if ( $migration_version < 55 ) {
                global $wpdb;
                $customers = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'customers';
                $exists    = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $customers ) );
                if ( $exists ) {
                    if ( ! $wpdb->get_row( "SHOW COLUMNS FROM {$customers} LIKE 'referral_code'" ) ) {
                        $wpdb->query( "ALTER TABLE {$customers} ADD COLUMN referral_code VARCHAR(32) NULL AFTER date_of_birth" );
                    }
                    if ( ! $wpdb->get_row( "SHOW COLUMNS FROM {$customers} LIKE 'referred_by'" ) ) {
                        $wpdb->query( "ALTER TABLE {$customers} ADD COLUMN referred_by BIGINT UNSIGNED NULL AFTER referral_code" );
                    }
                    if ( ! $wpdb->get_row( "SHOW INDEX FROM {$customers} WHERE Key_name = 'uq_referral_code'" ) ) {
                        $wpdb->query( "ALTER TABLE {$customers} ADD UNIQUE KEY uq_referral_code (referral_code)" );
                    }
                    if ( ! $wpdb->get_row( "SHOW INDEX FROM {$customers} WHERE Key_name = 'idx_referred_by'" ) ) {
                        $wpdb->query( "ALTER TABLE {$customers} ADD KEY idx_referred_by (referred_by)" );
                    }
                }
                update_option( 'titancart_migration_version', 55, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 56 — tc_product_features.show_on_pdp (S300, attribute/filter overhaul #5)
            // ─────────────────────────────────────────────────────────────────
            // Decoupling Display Type from Purpose (S299) let an attribute be a
            // pure storefront `filter` (e.g. "Color Family") with no role on the
            // product detail page. But the PDP renders feature values through two
            // paths in templates/product/single.php that gate on TYPE not purpose:
            // the "visual indicators" swatch block (any non-axis type-E feature)
            // and the Specifications table. So a filter-only Color Family attribute
            // still surfaced on the PDP. This adds the explicit visibility flag the
            // PDP template (and the admin editor) read to suppress it.
            //
            //   show_on_pdp — TINYINT(1) NOT NULL DEFAULT 1. 1 = render on the PDP
            //                 (specs table + visual swatches), preserving every
            //                 existing attribute's behavior. 0 = keep it off the PDP
            //                 (Sean unchecks it for Color Family). The storefront
            //                 sidebar filter + product-card swatches are unaffected
            //                 (they gate on purpose/filter_type, not this flag).
            // Schema::product_features() carries the column for fresh installs.
            // Plain ADD COLUMN (dbDelta-safe) but guarded SHOW-COLUMNS for idempotency.
            if ( $migration_version < 56 ) {
                global $wpdb;
                $features = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_features';
                $exists   = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $features ) );
                if ( $exists ) {
                    if ( ! $wpdb->get_row( "SHOW COLUMNS FROM {$features} LIKE 'show_on_pdp'" ) ) {
                        $wpdb->query( "ALTER TABLE {$features} ADD COLUMN show_on_pdp TINYINT(1) NOT NULL DEFAULT 1 AFTER compareable" );
                    }
                }
                update_option( 'titancart_migration_version', 56, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 57 — tc_notifications (S304, KI-26 admin notification bell)
            // ─────────────────────────────────────────────────────────────────
            // Self-contained store for admin notifications generated from the
            // engine's own action hooks (new order, marketplace vendor application).
            // Fresh installs get the table from Schema::notifications(); this creates
            // it for existing installs. Guarded by SHOW TABLES for idempotency.
            if ( $migration_version < 57 ) {
                global $wpdb;
                $charset = $wpdb->get_charset_collate();
                $table   = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'notifications';
                $exists  = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
                if ( ! $exists ) {
                    $wpdb->query( "CREATE TABLE {$table} (
                        id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                        type          VARCHAR(50)   NOT NULL DEFAULT 'general',
                        title         VARCHAR(255)  NOT NULL DEFAULT '',
                        body          VARCHAR(512)  NOT NULL DEFAULT '',
                        link          VARCHAR(255)  NOT NULL DEFAULT '',
                        object_type   VARCHAR(50)   NOT NULL DEFAULT '',
                        object_id     BIGINT UNSIGNED NOT NULL DEFAULT 0,
                        is_read       TINYINT(1)    NOT NULL DEFAULT 0,
                        read_at       DATETIME      DEFAULT NULL,
                        created_at    DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
                        PRIMARY KEY (id),
                        KEY idx_unread (is_read, created_at),
                        KEY idx_type (type),
                        KEY idx_object (object_type, object_id)
                    ) {$charset}" );
                }
                update_option( 'titancart_migration_version', 57, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 58 — tc_orders.referral_source (S323, Essentials #8)
            // ─────────────────────────────────────────────────────────────────
            // Persists the optional "How did you hear about us?" checkout answer
            // on the order. Fresh installs get the column + index from
            // Schema::orders(); this adds them for existing installs. Guarded by
            // SHOW COLUMNS / SHOW INDEX for idempotency.
            if ( $migration_version < 58 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'orders';
                $cols  = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 );
                if ( ! in_array( 'referral_source', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN referral_source VARCHAR(100) NULL AFTER customer_note" );
                }
                $idx = $wpdb->get_results( "SHOW INDEX FROM {$table}" );
                $idx_names = is_array( $idx ) ? array_column( $idx, 'Key_name' ) : [];
                if ( ! in_array( 'idx_referral_source', $idx_names, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD KEY idx_referral_source (referral_source)" );
                }
                update_option( 'titancart_migration_version', 58, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 59 — tc_orders.tip_total (Tip / Donation extension)
            // ─────────────────────────────────────────────────────────────────
            // Persists a customer-chosen tip / donation (round-up, preset, or
            // custom amount) on the order. The amount is server-computed in
            // CheckoutManager::resolve_tip() and rides inside the order `total`,
            // so every gateway collects it automatically. Core ships this column
            // INERT — nothing populates it until the paid Tip/Donation extension
            // supplies the `titancart_tip_config` filter. Fresh installs get the
            // column from Schema::orders(); this adds it for existing installs.
            // Guarded by SHOW COLUMNS for idempotency. No index (a money column,
            // never queried by value — mirrors payment_surcharge).
            if ( $migration_version < 59 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'orders';
                $cols  = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 );
                if ( ! in_array( 'tip_total', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN tip_total DECIMAL(12,4) NOT NULL DEFAULT 0.0000 AFTER payment_surcharge" );
                }
                update_option( 'titancart_migration_version', 59, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 60 — tc_product_groups.display_as_single + display_name
            // ─────────────────────────────────────────────────────────────────
            // roadmap #15: a group-level "display as a single product" toggle so
            // a separate-SKU variation group (each size = its own product) shows
            // ONE representative card on the storefront grid/search/sitemap while
            // the PDP still collapses to a single product with the selector.
            // Fresh installs get both columns from Schema::product_groups(); this
            // adds them for existing installs. Both guarded by SHOW COLUMNS for
            // idempotency. display_as_single defaults 0 = current behavior
            // (backward-compatible, no data change). No index (small table,
            // never filtered by these columns alone).
            if ( $migration_version < 60 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_groups';
                $cols  = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 );
                if ( ! in_array( 'display_as_single', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN display_as_single TINYINT(1) NOT NULL DEFAULT 0 AFTER feature_ids" );
                }
                if ( ! in_array( 'display_name', $cols, true ) ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN display_name VARCHAR(255) NOT NULL DEFAULT '' AFTER display_as_single" );
                }
                update_option( 'titancart_migration_version', 60, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 61 — Page Registry adoption (roadmap #2, Page Manager S1)
            // ─────────────────────────────────────────────────────────────────
            // Lifts the scattered, per-feature page-creation logic (Migration 23
            // gift_cards, Migration 34 testimonials) into one canonical registry:
            // TitanCart\Modules\Pages\PageRegistry. This re-drives the option-
            // bound registry pages through the registry's single idempotent
            // ensure() path so every titancart_page_* option is (re)bound from
            // ONE place. ensure() ADOPTS an existing page (option → shortcode →
            // slug) and only creates one that is genuinely absent — so on any
            // already-migrated install this is a no-op adopt: no duplicate pages,
            // no content change. Migrations 23/34 are left intact for historical
            // installs; new page creation now routes through the registry.
            //
            // bound_only = true: structural pages (homepage/blog) are deliberately
            // excluded here — their front-page / posts-page binding stays owned by
            // Migration 23. Only the option-bound feature pages (gift_cards,
            // testimonials) are re-asserted. class_exists guard keeps boot safe if
            // the class is ever unavailable.
            if ( $migration_version < 61 ) {
                if ( class_exists( \TitanCart\Modules\Pages\PageRegistry::class ) ) {
                    \TitanCart\Modules\Pages\PageRegistry::ensure_all( true );
                }
                update_option( 'titancart_migration_version', 61, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 62 — tc_import_presets (KI-34, saved import presets)
            // ─────────────────────────────────────────────────────────────────
            // Creates the tc_import_presets table for the Pro "Advanced Products
            // Import" feature: a named, reusable CSV-mapping preset per entity
            // type. Fresh installs get the table from Schema::import_presets();
            // this adds it for existing installs via dbDelta (create-only,
            // idempotent — dbDelta never drops the table or its data if present).
            if ( $migration_version < 62 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );
                update_option( 'titancart_migration_version', 62, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 63 — tc_product_features.sort_mode (roadmap #11)
            // ─────────────────────────────────────────────────────────────────
            // Per-attribute ordering of choices: 'manual' (position column, the
            // historical behaviour) or 'alpha' (name A–Z, collation-aware).
            // Explicit ALTER with a SHOW COLUMNS guard (same shape as Migration 2)
            // rather than dbDelta so the DEFAULT is guaranteed on every engine.
            // Default 'manual' → zero behaviour change for existing stores.
            if ( $migration_version < 63 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_features';
                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table = $wpdb->prefix . constant; DDL over literals.
                $col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'sort_mode'" );
                if ( ! $col ) {
                    $wpdb->query( "ALTER TABLE {$table} ADD COLUMN sort_mode VARCHAR(10) NOT NULL DEFAULT 'manual' AFTER show_on_pdp" );
                }
                // phpcs:enable
                update_option( 'titancart_migration_version', 63, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 64 — tc_demo_import_log (Prism demo importer, S471)
            // ─────────────────────────────────────────────────────────────────
            // Ledger of every row the demo importer wrote or generated, keyed by
            // (preset, entity_type, source_key). Powers idempotent re-install,
            // clean removal on preset switch, "Clear demo sample data", and the
            // fingerprint-based dirty check that locks the theme once the user
            // customises demo content (DEMO_IMPORT_PLAN.md §RESOLVED at S470/S471).
            // Same create-only dbDelta shape as Migration 62; fresh installs get
            // the table from Schema::demo_import_log().
            if ( $migration_version < 64 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );
                update_option( 'titancart_migration_version', 64, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 65 — drop the legacy base-price rows (KI-122, S478)
            // ─────────────────────────────────────────────────────────────────
            // Every product created before 0.2.22 got an automatic
            // tc_product_prices row (usergroup 0 · min qty 1 · absolute = the
            // price at creation). The editor's Bulk Buys tab loaded it and re-sent
            // it unchanged on every save, so after a price change get_price()
            // kept serving the stale copy as the lowest matching tier and the
            // storefront showed the OLD price. tc_products.price is the base price
            // (get_price() falls back to it); such rows are no longer written or
            // read, so delete them. Idempotent — a re-run deletes nothing.
            if ( $migration_version < 65 ) {
                global $wpdb;
                $table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'product_prices';
                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table = $wpdb->prefix . constant; DML over literals.
                $wpdb->query( "DELETE FROM {$table} WHERE usergroup_id = 0 AND min_quantity = 1 AND discount_type = 'absolute'" );
                // phpcs:enable
                update_option( 'titancart_migration_version', 65, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 66 — heal variation groups with deleted axes (KI-124, S478)
            // ─────────────────────────────────────────────────────────────────
            // Deleting an attribute (or a choice with no same-name survivor)
            // left tc_product_groups.feature_ids and member combinations pointing
            // at ids that no longer exist ("Attr #2 / Variant #6" on the Product
            // Group tab). Strip dead ids, drop members whose choice is gone, and
            // disband groups with no axis left — members stay as products.
            // Idempotent: a healthy store changes nothing.
            if ( $migration_version < 66 ) {
                $healed = \TitanCart\Modules\Products\ProductRepository::heal_orphaned_groups();
                if ( ! empty( $healed['groups_touched'] ) ) {
                    titancart_log( '[TitanCart] Migration 66 healed variation groups: ' . wp_json_encode( $healed ) );
                }
                update_option( 'titancart_migration_version', 66, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 67 — FAQ table (S478, Sean)
            // ─────────────────────────────────────────────────────────────────
            // Creates tc_faqs for the Storefront → FAQ manager + [titancart_faq]
            // shortcode. dbDelta over the full schema is idempotent.
            if ( $migration_version < 67 ) {
                // Page creation needs $wp_rewrite → must run at init or later (same
                // guard as Migration 23). Defer the WHOLE remainder so the dbDelta
                // below does not re-run on every request while the block waits.
                if ( ! did_action( 'init' ) ) {
                    add_action( 'init', [ self::class, 'maybe_run_migrations' ], 0 );
                    return;
                }

                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );

                // Page work is best-effort: any failure here must not leave the
                // version unstamped (that would replay the dbDelta above on every
                // request — 0.2.25 on aiab did exactly that, ~2.5 s per request).
                try {
                    if ( class_exists( \TitanCart\Modules\Pages\PageRegistry::class ) ) {
                        // Give existing stores the FAQ page (adopt-or-create, never duplicates).
                        \TitanCart\Modules\Pages\PageRegistry::ensure_all( true );

                        // KI-125: Lite stores got an empty "Gift Cards" page from the
                        // registry even though the module is Pro-only. Trash it if it
                        // is still the untouched auto-created page (content = the
                        // shortcode alone); a page the owner has edited is left alone.
                        // NOTE: edition check via Edition::is_pro() — the Lite build
                        // drops any Plugin.php line naming the Pro module classes.
                        if ( ! \TitanCart\Core\Edition::is_pro() ) {
                            $gc_id = (int) get_option( 'titancart_page_gift_cards', 0 );
                            $gc    = $gc_id > 0 ? get_post( $gc_id ) : null;
                            if ( $gc && 'page' === $gc->post_type && '[titancart_gift_cards]' === trim( (string) $gc->post_content ) ) {
                                wp_trash_post( $gc_id );
                                delete_option( 'titancart_page_gift_cards' );
                            }
                        }
                    }
                } catch ( \Throwable $e ) {
                    titancart_log( '[TitanCart] Migration 67 page step skipped: ' . $e->getMessage() );
                }

                update_option( 'titancart_migration_version', 67, false );
            }

            // ─────────────────────────────────────────────────────────────────
            // Migration 68 — site-wide attribute prices (S478, Sean)
            // ─────────────────────────────────────────────────────────────────
            // product_features.global_prices + product_feature_variants.price_modifier
            // (dbDelta adds the columns; both nullable/defaulted, no data change).
            if ( $migration_version < 68 ) {
                require_once ABSPATH . 'wp-admin/includes/upgrade.php';
                dbDelta( \TitanCart\DB\Schema::all() );
                update_option( 'titancart_migration_version', 68, false );
                // ⚠️ Adding Migration 69+? Bump Plugin::MIGRATION_LATEST above
                // in the same commit (fresh installs stamp it at activation).
            }
        } catch ( \Throwable $e ) {
            // Log but never crash boot — route registration must proceed.
            titancart_log( '[TitanCart] Migration error: ' . $e->getMessage() );
        }
    }
}
