<?php
/**
 * TitanCart Installer.
 *
 * Handles plugin activation, deactivation, and first-run setup.
 * Creates all 70 database tables via dbDelta() on activation.
 *
 * @package TitanCart\Core
 */

namespace TitanCart\Core;

use TitanCart\DB\Schema;

defined( 'ABSPATH' ) || exit;

class Installer {

    /**
     * Plugin activation callback.
     *
     * Runs when the plugin is activated via WP admin or WP CLI.
     * Creates DB tables, inserts seed data, sets version option.
     */
    public static function activate(): void {
        if ( ! current_user_can( 'activate_plugins' ) ) {
            return;
        }

        // Capture fresh-install flag BEFORE create_tables() sets titancart_db_version.
        $is_fresh = ! get_option( 'titancart_db_version' );

        self::check_requirements();
        self::create_tables();

        if ( $is_fresh ) {
            self::seed_data();

            // S468: Schema::all() (just applied by create_tables) IS the
            // end-state, so a fresh install starts at the latest migration —
            // replaying 63 historical migrations against a brand-new schema is
            // waste at best and, pre-init, actively harmful (Playground/SQLite
            // find: dbDelta replays emit broken ALTERs; Migration 23 creates
            // pages, which is unsupported before init). Migrations remain the
            // upgrade path for existing sites only.
            update_option( 'titancart_migration_version', Plugin::MIGRATION_LATEST, false );

            // Storefront pages + reading-settings binding. Historically owned
            // by Migration 23, which fresh installs now skip. Activation runs
            // inside an admin request (init long past), so page creation is
            // safe here. ensure_all() is adopt-or-create — never duplicates.
            if ( class_exists( \TitanCart\Modules\Pages\PageRegistry::class ) ) {
                \TitanCart\Modules\Pages\PageRegistry::ensure_all();

                $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 );
                }
            }
        }

        // Upgrade-safe seeders — run every activation, idempotent.
        self::seed_default_tabs();
        \TitanCart\DB\CountrySeeder::seed();

        self::set_version();
        self::schedule_cron();
        // S405 (KI-40): seed_trial_timestamp() removed — the trial is retired.

        // Flush rewrite rules after setting up our endpoints.
        flush_rewrite_rules();
    }

    /**
     * Plugin deactivation callback.
     */
    public static function deactivate(): void {
        self::clear_cron();
        flush_rewrite_rules();
    }

    // ── Private methods ───────────────────────────────────────────────────────

    /**
     * Verify minimum PHP and WP versions before proceeding.
     *
     * @throws \RuntimeException If requirements are not met.
     */
    private static function check_requirements(): void {
        if ( version_compare( PHP_VERSION, TITANCART_MIN_PHP, '<' ) ) {
            deactivate_plugins( TITANCART_FILE );
            wp_die(
                sprintf(
                    /* translators: %s: required PHP version */
                    esc_html__( 'TitanCart requires PHP %s or higher. Please upgrade your PHP version.', 'titancart-lite' ),
                    esc_html( TITANCART_MIN_PHP )
                )
            );
        }

        if ( version_compare( get_bloginfo( 'version' ), TITANCART_MIN_WP, '<' ) ) {
            deactivate_plugins( TITANCART_FILE );
            wp_die(
                sprintf(
                    /* translators: %s: required WP version */
                    esc_html__( 'TitanCart requires WordPress %s or higher.', 'titancart-lite' ),
                    esc_html( TITANCART_MIN_WP )
                )
            );
        }
    }

    /**
     * Create all plugin tables using dbDelta().
     *
     * dbDelta() is safe to run multiple times — it only creates
     * or modifies tables, never drops columns.
     */
    private static function create_tables(): void {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        global $wpdb;

        $sql_statements = Schema::all();

        // ── KI-104: wp_tc_settings must BYPASS dbDelta ─────────────────────
        // The table has a column literally named `key`. dbDelta's field-line
        // router (wp-admin/includes/upgrade.php, the PRIMARY/INDEX/FULLTEXT/
        // UNIQUE/KEY/SPATIAL switch) classifies ANY line whose first token is
        // one of those keywords as an INDEX definition; its index regex then
        // fails against the column line, and the empty match becomes a phantom
        // index — emitting four "Undefined array key" warnings and a malformed
        // `ALTER TABLE ... ADD `` (``)` on every activation where the table
        // already exists. This is NOT fixable by formatting: the column NAME
        // is the trigger (proven S437 against the real WP 7.0.1 dbDelta +
        // MariaDB 10.11 with renamed-column and minimal-trigger controls; see
        // KNOWN_ISSUES KI-104). No index is actually lost either way — the
        // phantom is an extra, not a real index failing.
        // So: run the settings CREATE directly (IF NOT EXISTS makes re-runs a
        // no-op) and skip dbDelta for that one statement. Any FUTURE change to
        // the settings table must go through run_column_migrations() below —
        // dbDelta will never manage this table.
        $settings_table = $wpdb->prefix . TITANCART_TABLE_PREFIX . 'settings';

        foreach ( $sql_statements as $sql ) {
            if ( 0 === strpos( $sql, "CREATE TABLE {$settings_table} (" ) ) {
                $wpdb->query( str_replace( 'CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS ', $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is Schema.php's own literal CREATE for the tc_settings table (activation DDL, no user input); see KI-104 rationale above.
                continue;
            }
            dbDelta( $sql );
        }

        // ── Post-dbDelta migrations (ENUM changes, column adds, etc.) ──────
        // dbDelta cannot modify ENUM values, so we handle those here.
        self::run_column_migrations();

        // Store the installed DB version for future migrations.
        update_option( 'titancart_db_version', TITANCART_VERSION, false );
    }

    /**
     * Run ALTER TABLE statements that dbDelta cannot handle.
     *
     * Each migration checks if it's already been applied before running.
     */
    private static function run_column_migrations(): void {
        global $wpdb;
        $prefix = $wpdb->prefix . 'tc_';

        // 1. product_filters.display_type — add 'size_pills' to ENUM
        $col = $wpdb->get_row( "SHOW COLUMNS FROM {$prefix}product_filters LIKE 'display_type'" ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $prefix = $wpdb->prefix . 'tc_' (constants, never user input); activation-time migration DDL.
        if ( $col && strpos( $col->Type, 'size_pills' ) === false ) {
            $wpdb->query( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $prefix = $wpdb->prefix . 'tc_' (constants); literal migration DDL.
                "ALTER TABLE {$prefix}product_filters
                 MODIFY COLUMN display_type ENUM('checkbox','slider','color','range','size_pills') NOT NULL DEFAULT 'checkbox'"
            );
        }
    }

    /**
     * Insert essential seed data on first install.
     *
     * Only runs when no previous version is detected.
     */
    private static function seed_data(): void {
        // Guard is handled by the caller (activate()). This method is only
        // invoked when $is_fresh === true, so no version check needed here.
        global $wpdb;
        $p = $wpdb->prefix . TITANCART_TABLE_PREFIX;

        // ── Default currency (USD) ────────────────────────────────────────────
        $wpdb->insert( $p . 'currencies', [
            'code'          => 'USD',
            'symbol'        => '$',
            'symbol_after'  => 0,
            'name'          => 'US Dollar',
            'decimals'      => 2,
            'exchange_rate' => 1.000000,
            'is_primary'    => 1,
            'status'        => 'active',
        ] );

        // ── Default language (English) ────────────────────────────────────────
        $wpdb->insert( $p . 'languages', [
            'code'        => 'en',
            'name'        => 'English',
            'native_name' => 'English',
            'is_default'  => 1,
            'status'      => 'active',
        ] );

        // ── Default order statuses ────────────────────────────────────────────
        $statuses = [
            [ 'handle' => 'pending',    'color' => '#D9A441', 'position' => 10, 'is_default' => 1, 'notify_customer' => 0, 'stock_action' => 'none' ],
            [ 'handle' => 'processing', 'color' => '#1F6AE1', 'position' => 20, 'is_default' => 0, 'notify_customer' => 1, 'stock_action' => 'none' ],
            [ 'handle' => 'on_hold',    'color' => '#71797E', 'position' => 30, 'is_default' => 0, 'notify_customer' => 0, 'stock_action' => 'none' ],
            [ 'handle' => 'completed',  'color' => '#1F8A70', 'position' => 40, 'is_default' => 0, 'notify_customer' => 1, 'stock_action' => 'none' ],
            [ 'handle' => 'cancelled',  'color' => '#C84646', 'position' => 50, 'is_default' => 0, 'notify_customer' => 1, 'stock_action' => 'restore' ],
            [ 'handle' => 'refunded',   'color' => '#C84646', 'position' => 60, 'is_default' => 0, 'notify_customer' => 1, 'stock_action' => 'restore' ],
            [ 'handle' => 'failed',     'color' => '#C84646', 'position' => 70, 'is_default' => 0, 'notify_customer' => 0, 'stock_action' => 'none' ],
        ];

        $status_labels = [
            'pending'    => 'Pending Payment',
            'processing' => 'Processing',
            'on_hold'    => 'On Hold',
            'completed'  => 'Completed',
            'cancelled'  => 'Cancelled',
            'refunded'   => 'Refunded',
            'failed'     => 'Failed',
        ];

        foreach ( $statuses as $status ) {
            $wpdb->insert( $p . 'order_statuses', $status );
            $status_id = $wpdb->insert_id;
            $wpdb->insert( $p . 'order_status_translations', [
                'status_id'     => $status_id,
                'language_code' => 'en',
                'name'          => $status_labels[ $status['handle'] ],
            ] );
        }

        // ── Fulfillment + gift-card statuses ─────────────────────────────────
        // S468: historically seeded by Migrations 6+7, which fresh installs no
        // longer replay (activation stamps MIGRATION_LATEST). End-state
        // mirrored here: ff_pending is the fulfillment default.
        $extra_statuses = [
            [ 'ff_pending', 'fulfillment', 'Pending',   '#D9A441',  5, 1 ],
            [ 'picked_up',  'fulfillment', 'Picked Up', '#0891B2', 10, 0 ],
            [ 'packed',     'fulfillment', 'Packed',    '#71797E', 20, 0 ],
            [ 'shipped',    'fulfillment', 'Shipped',   '#1F8A70', 30, 0 ],
            [ 'gc_pending', 'gift_card',   'Pending',   '#D9A441', 10, 1 ],
            [ 'gc_active',  'gift_card',   'Active',    '#1F8A70', 20, 0 ],
            [ 'gc_disabled','gift_card',   'Disabled',  '#71797E', 30, 0 ],
            [ 'gc_used',    'gift_card',   'Used',      '#1F6AE1', 40, 0 ],
        ];
        foreach ( $extra_statuses as $s ) {
            $wpdb->insert( $p . 'order_statuses', [
                'handle'          => $s[0],
                'type'            => $s[1],
                'color'           => $s[3],
                'position'        => $s[4],
                'is_default'      => $s[5],
                'notify_customer' => 0,
                'stock_action'    => 'none',
            ] );
            $sid = $wpdb->insert_id;
            if ( $sid ) {
                $wpdb->insert( $p . 'order_status_translations', [
                    'status_id'     => $sid,
                    'language_code' => 'en',
                    'name'          => $s[2],
                ] );
            }
        }

        // ── Default usergroups ────────────────────────────────────────────────
        $usergroups = [
            [ 'handle' => 'guest',    'name' => 'Guest' ],
            [ 'handle' => 'customer', 'name' => 'Customer' ],
            [ 'handle' => 'wholesale','name' => 'Wholesale' ],
        ];

        foreach ( $usergroups as $group ) {
            $wpdb->insert( $p . 'usergroups', [ 'handle' => $group['handle'], 'status' => 'active' ] );
            $group_id = $wpdb->insert_id;
            $wpdb->insert( $p . 'usergroup_translations', [
                'group_id'      => $group_id,
                'language_code' => 'en',
                'name'          => $group['name'],
            ] );
        }

        // ── Default tax class ─────────────────────────────────────────────────
        $wpdb->insert( $p . 'tax_classes', [ 'handle' => 'standard', 'status' => 'active' ] );
        $tax_class_id = $wpdb->insert_id;
        $wpdb->insert( $p . 'tax_class_translations', [
            'class_id'      => $tax_class_id,
            'language_code' => 'en',
            'name'          => 'Standard Rate',
        ] );

        // ── Countries + States ───────────────────────────────────────────────
        \TitanCart\DB\CountrySeeder::seed();

        // ── Reward-point earning rules + return reasons ──────────────────────
        // S468: historically seeded by Migrations 14 and 27, which fresh
        // installs no longer replay. Guarded by SHOW TABLES because either
        // table may be absent in a build variant; empty-check keeps re-runs
        // idempotent. Mirrors the migrations' rows exactly.
        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'reward_point_rules' ) ) ) {
            $rc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$p}reward_point_rules" ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $p = $wpdb->prefix . 'tc_' (constants, never user input); seed check.
            if ( $rc === 0 ) {
                $rules = [
                    [ '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 ( $rules as $r ) {
                    $wpdb->insert( $p . 'reward_point_rules', [
                        'event'         => $r[0],
                        'name'          => $r[1],
                        'points_amount' => $r[2],
                        'points_type'   => $r[3],
                        'min_order'     => $r[4],
                        'position'      => $r[5],
                        'status'        => 'active',
                    ] );
                }
            }
        }
        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $p . 'return_reasons' ) ) ) {
            $rrc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$p}return_reasons" ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $p = $wpdb->prefix . 'tc_' (constants, never user input); seed check.
            if ( $rrc === 0 ) {
                $reasons = [
                    [ 'Defective or damaged', 1 ], [ 'Wrong item received', 2 ],
                    [ 'Changed my mind', 3 ], [ 'No longer needed', 4 ],
                    [ 'Arrived too late', 5 ], [ 'Item doesn\'t match description', 6 ],
                ];
                foreach ( $reasons as $reason ) {
                    $wpdb->insert( $p . 'return_reasons', [
                        'name'     => $reason[0],
                        'position' => $reason[1],
                        'status'   => 'active',
                    ], [ '%s', '%d', '%s' ] );
                }
            }
        }

        // ── Email templates ──────────────────────────────────────────────────
        // NOT seeded here. The stale block that lived here inserted the
        // pre-migration-10 handle/event shape into the slug/receiver table and
        // failed silently on every fresh install (surfaced by Playground/SQLite
        // with debug display, S468/WP.org R3 testing). Defaults are seeded
        // lazily on first access by NotificationsController::seed_default_templates()
        // — full localized bodies, not just handles — which is the design.

        // ── Default customer fields (profile fields) ──────────────────────────
        $customer_fields = [
            // Shipping address
            [ 'section' => 'shipping', 'field_name' => 'first_name',  'field_type' => 'input',    'description' => 'First name',       'position' => 10, 'is_visible' => 1, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'last_name',   'field_type' => 'input',    'description' => 'Last name',        'position' => 20, 'is_visible' => 1, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'address_1',   'field_type' => 'input',    'description' => 'Address',          'position' => 30, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'address_2',   'field_type' => 'input',    'description' => 'Address',          'position' => 40, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 0 ],
            [ 'section' => 'shipping', 'field_name' => 'city',        'field_type' => 'input',    'description' => 'City',             'position' => 50, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'country',     'field_type' => 'country',  'description' => 'Country',          'position' => 60, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'state',       'field_type' => 'state',    'description' => 'State/province',   'position' => 70, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'zipcode',     'field_type' => 'zipcode',  'description' => 'Zip/postal code',  'position' => 80, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            [ 'section' => 'shipping', 'field_name' => 'phone',       'field_type' => 'phone',    'description' => 'Phone',            'position' => 90, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 0 ],
            [ 'section' => 'shipping', 'field_name' => 'address_type','field_type' => 'select',   'description' => 'Address type',     'position' =>100, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 0 ],
            // Contact information
            [ 'section' => 'contact',  'field_name' => 'first_name',  'field_type' => 'input',    'description' => 'First name',       'position' => 10, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            [ 'section' => 'contact',  'field_name' => 'last_name',   'field_type' => 'input',    'description' => 'Last name',        'position' => 20, 'is_visible' => 1, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'contact',  'field_name' => 'company',     'field_type' => 'input',    'description' => 'Company',          'position' => 30, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 0 ],
            [ 'section' => 'contact',  'field_name' => 'phone',       'field_type' => 'phone',    'description' => 'Phone',            'position' => 40, 'is_visible' => 1, 'is_required' => 0, 'is_system' => 0 ],
            [ 'section' => 'contact',  'field_name' => 'email',       'field_type' => 'email',    'description' => 'E-mail',           'position' => 50, 'is_visible' => 1, 'is_required' => 1, 'is_system' => 1 ],
            // Billing address
            [ 'section' => 'billing',  'field_name' => 'first_name',  'field_type' => 'input',    'description' => 'First name',       'position' => 10, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'last_name',   'field_type' => 'input',    'description' => 'Last name',        'position' => 20, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'address_1',   'field_type' => 'input',    'description' => 'Address',          'position' => 30, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'address_2',   'field_type' => 'input',    'description' => 'Address',          'position' => 40, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 0 ],
            [ 'section' => 'billing',  'field_name' => 'city',        'field_type' => 'input',    'description' => 'City',             'position' => 50, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'country',     'field_type' => 'country',  'description' => 'Country',          'position' => 60, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'state',       'field_type' => 'state',    'description' => 'State/province',   'position' => 70, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'zipcode',     'field_type' => 'zipcode',  'description' => 'Zip/postal code',  'position' => 80, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 1 ],
            [ 'section' => 'billing',  'field_name' => 'phone',       'field_type' => 'phone',    'description' => 'Phone',            'position' => 90, 'is_visible' => 0, 'is_required' => 0, 'is_system' => 0 ],
        ];

        foreach ( $customer_fields as $cf ) {
            $wpdb->insert( $p . 'customer_fields', array_merge( $cf, [ 'status' => 'active' ] ) );
        }

        // ── Default plugin settings ───────────────────────────────────────────
        $settings = [
            [ 'section' => 'general',  'key' => 'store_name',         'value' => get_bloginfo( 'name' ) ],
            [ 'section' => 'general',  'key' => 'store_email',        'value' => get_bloginfo( 'admin_email' ) ],
            [ 'section' => 'general',  'key' => 'currency',           'value' => 'USD' ],
            [ 'section' => 'general',  'key' => 'language',           'value' => 'en' ],
            [ 'section' => 'catalog',  'key' => 'products_per_page',  'value' => '24' ],
            [ 'section' => 'catalog',  'key' => 'default_sort',       'value' => 'popularity' ],
            [ 'section' => 'tax',      'key' => 'prices_include_tax', 'value' => '0' ],
            [ 'section' => 'tax',      'key' => 'tax_based_on',       'value' => 'shipping_address' ],
            [ 'section' => 'shipping', 'key' => 'ship_to_countries',  'value' => 'all' ],
        ];

        foreach ( $settings as $setting ) {
            $wpdb->insert( $p . 'settings', array_merge( $setting, [ 'autoload' => 1 ] ) );
        }

        // ── Default product tabs (storefront) ────────────────────────────────
        $default_tabs = [
            [ 'tab_type' => 'builtin',       'tab_key' => 'description',  'position' => 10, 'is_primary' => 1, 'status' => 'active',   'title' => 'Description' ],
            [ 'tab_type' => 'builtin',       'tab_key' => 'attributes',   'position' => 20, 'is_primary' => 1, 'status' => 'active',   'title' => 'Attributes' ],
            [ 'tab_type' => 'builtin',       'tab_key' => 'reviews',      'position' => 30, 'is_primary' => 1, 'status' => 'active',   'title' => 'Reviews' ],
            [ 'tab_type' => 'sizing_chart',  'tab_key' => 'sizing_chart', 'position' => 40, 'is_primary' => 0, 'status' => 'disabled', 'title' => 'Sizing Chart' ],
        ];

        foreach ( $default_tabs as $tab ) {
            $title = $tab['title'];
            unset( $tab['title'] );
            $wpdb->insert( $p . 'product_tabs', $tab );
            $tab_id = $wpdb->insert_id;
            $wpdb->insert( $p . 'product_tab_translations', [
                'tab_id'        => $tab_id,
                'language_code' => 'en',
                'title'         => $title,
            ] );
        }
    }

    /**
     * Seed default product tabs if they don't already exist.
     *
     * Idempotent — safe to call on every activation. Checks by tab_key
     * before inserting, so existing installs get the new tabs without
     * duplicating anything.
     */
    private static function seed_default_tabs(): void {
        global $wpdb;
        $p = $wpdb->prefix . TITANCART_TABLE_PREFIX;

        $default_tabs = [
            [ 'tab_type' => 'builtin',       'tab_key' => 'description',  'position' => 10, 'is_primary' => 1, 'status' => 'active',   'title' => 'Description' ],
            [ 'tab_type' => 'builtin',       'tab_key' => 'attributes',   'position' => 20, 'is_primary' => 1, 'status' => 'active',   'title' => 'Attributes' ],
            [ 'tab_type' => 'builtin',       'tab_key' => 'reviews',      'position' => 30, 'is_primary' => 1, 'status' => 'active',   'title' => 'Reviews' ],
            [ 'tab_type' => 'sizing_chart',  'tab_key' => 'sizing_chart', 'position' => 40, 'is_primary' => 0, 'status' => 'disabled', 'title' => 'Sizing Chart' ],
        ];

        foreach ( $default_tabs as $tab ) {
            $title = $tab['title'];
            unset( $tab['title'] );

            // Skip if this tab_key already exists (global tabs have product_id IS NULL).
            $exists = $wpdb->get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM {$p}product_tabs WHERE tab_key = %s AND product_id IS NULL",
                $tab['tab_key']
            ) );

            if ( (int) $exists > 0 ) {
                continue;
            }

            $wpdb->insert( $p . 'product_tabs', $tab );
            $tab_id = $wpdb->insert_id;

            if ( $tab_id ) {
                $wpdb->insert( $p . 'product_tab_translations', [
                    'tab_id'        => $tab_id,
                    'language_code' => 'en',
                    'title'         => $title,
                ] );
            }
        }
    }

    /**
     * Store the current plugin version in wp_options.
     */
    private static function set_version(): void {
        update_option( 'titancart_version', TITANCART_VERSION, false );
    }

    /*
     * seed_trial_timestamp() — REMOVED (KI-40, S405).
     *
     * Seeded `tc_trial_started_at` on first activation (seeds-if-missing,
     * ungated). The trial is retired — Lite is the trial — so nothing starts
     * a clock any more and nothing reads one.
     *
     * ⚠️ WHY THIS WAS THE DEFECT, recorded so it is not rebuilt: this method
     * was the option's ONLY writer, and the tree had ZERO deleters. Licence
     * activation never wrote it, so the clock was never consumed or paused —
     * it ticked from first activation and was simply not CONSULTED while a
     * valid key made arm 2 match first. The moment a key lapsed, the trial arm
     * asked only "is the window open NOW?" and handed the unused remainder
     * back as unpaid Pro (activate day 1, lapse day 10 ⇒ 20 free days).
     * Invisible to anyone lapsing after day 30, which is why it went unreported
     * for so long. Any future trial MUST be consumed at activation.
     */

    /**
     * Register WP Cron events for background jobs.
     */
    private static function schedule_cron(): void {
        if ( ! wp_next_scheduled( 'titancart_process_jobs' ) ) {
            wp_schedule_event( time(), 'every_minute', 'titancart_process_jobs' );
        }
        if ( ! wp_next_scheduled( 'titancart_daily_snapshot' ) ) {
            wp_schedule_event( time(), 'daily', 'titancart_daily_snapshot' );
        }
        // LicenseManager is STRIPPED from the Lite package (build/manifest.php
        // $lite_exclude). Its cron only checks paid-extension/core entitlement
        // against the marketplace — meaningless in Lite, which has nothing to
        // authenticate. Guard so Lite activation doesn't fatal on a missing
        // class (KI-103). Same class_exists() contract Plugin.php uses for every
        // other phone-home surface.
        if ( class_exists( \TitanCart\Core\LicenseManager::class )
            && ! wp_next_scheduled( \TitanCart\Core\LicenseManager::CRON_HOOK ) ) {
            // Offset by one hour so the first run doesn't collide with activation work.
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', \TitanCart\Core\LicenseManager::CRON_HOOK );
        }
        // S116: orphan pending-order cleanup. Offset by 2 hours so first run
        // doesn't pile onto activation or the license cron's first dispatch.
        if ( ! wp_next_scheduled( \TitanCart\Modules\Orders\OrphanOrderCleanup::CRON_HOOK ) ) {
            wp_schedule_event( time() + ( 2 * HOUR_IN_SECONDS ), 'daily', \TitanCart\Modules\Orders\OrphanOrderCleanup::CRON_HOOK );
        }
    }

    /**
     * Clear WP Cron events on deactivation.
     */
    private static function clear_cron(): void {
        wp_clear_scheduled_hook( 'titancart_process_jobs' );
        wp_clear_scheduled_hook( 'titancart_daily_snapshot' );
        // LicenseManager is stripped from Lite (KI-103). An unguarded static
        // reference here fatals on DEACTIVATION — and a plugin that fatals while
        // deactivating gets force-removed by WP, which is the "deactivate
        // self-deleted the plugin" behaviour observed on the Lite test site.
        // wp_clear_scheduled_hook() needs only the hook STRING, so on Lite
        // (where the event was never scheduled) there is simply nothing to clear.
        if ( class_exists( \TitanCart\Core\LicenseManager::class ) ) {
            wp_clear_scheduled_hook( \TitanCart\Core\LicenseManager::CRON_HOOK );
        }
        wp_clear_scheduled_hook( \TitanCart\Modules\Orders\OrphanOrderCleanup::CRON_HOOK );
        // class_exists: Lite strips the RewardPoints module (R1 §🅰). The hook
        // string is inlined as a literal fallback so uninstall still clears a
        // stale cron left by a Pro→Lite downgrade (const value:
        // RewardPointsModule.php 'titancart_rewards_birthday_scan' — keep in sync).
        if ( class_exists( \TitanCart\Modules\RewardPoints\RewardPointsModule::class ) ) {
            wp_clear_scheduled_hook( \TitanCart\Modules\RewardPoints\RewardPointsModule::BIRTHDAY_CRON_HOOK );
        } else {
            wp_clear_scheduled_hook( 'titancart_rewards_birthday_scan' );
        }
    }
}
