<?php

/**
 * Plugin Name:       QuickStock for WooCommerce
 * Plugin URI:        https://mlplugins.com/quickstock/
 * Description:       Stock manager with smart reorder workflow, bulk editing, barcode labels and stock history for WooCommerce.
 * Version:           5.13.22
 * Requires at least: 6.2
 * Requires PHP:      7.4
 * Requires Plugins:  woocommerce
 * Author:            MLPlugins
 * Author URI:        https://mlplugins.com
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       quickstock-for-woocommerce
 *
 *
 * @package QuickStockForWooCommerce
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
// === Doppel-Aktivierungs-Guard (Free + Pro parallel) – offizielles Freemius-Muster ===
// Free und Pro teilen denselben Code (identische Funktionen + Klassen). Sind beide
// Varianten als getrennte Plugins vorhanden, darf nur EINE ihren Code deklarieren,
// sonst: Fatal Error ("Cannot redeclare ...").
//
// WICHTIG – warum ein einfaches `if (defined(...)) return;` am Dateianfang NICHT
// genügt: PHP bindet UNBEDINGTE Top-Level-Funktionen/Klassen bereits zur
// COMPILE-Zeit (early binding), also schon beim `include` der Datei – bevor eine
// Laufzeit-Anweisung wie `return` überhaupt ausgeführt wird. Die zweite Kopie
// stürzte damit beim Einbinden ab, noch bevor der Guard greifen könnte (empirisch
// mit PHP 8.2 bestätigt). Lösung: der GESAMTE restliche Plugin-Code steht unten im
// `else`-Zweig – also in einem BEDINGTEN Block. Dadurch binden seine Deklarationen
// LATE (zur Laufzeit) und die zweite Kopie deklariert sie gar nicht erst.
//
// Existiert qsfw_fs() bereits, ist dies die zweite Kopie: Wir melden Freemius via
// set_basename() die Datei dieser Variante. Freemius verwaltet dann die Free/Pro-
// Koexistenz selbst und deaktiviert beim Aktivieren die jeweils andere Variante
// automatisch (siehe _activate_plugin_event_hook() im SDK) – der Kunde muss nichts
// von Hand abschalten.
if ( function_exists( 'qsfw_fs' ) ) {
    qsfw_fs()->set_basename( false, __FILE__ );
} else {
    // === Freemius SDK Integration ===
    // Helper-Funktion für den Zugriff auf die Freemius-Instanz. Bewusst auf
    // `qsfw_fs` benannt (nicht der von Freemius vorgeschlagene Name `qfw_fs`),
    // damit der Prefix zum restlichen Plugin (`qsfw_`) passt und `qsfw_is_pro()`
    // die Funktion ohne Sonderfall findet. Der Funktionsname ist rein lokal –
    // Freemius identifiziert das Plugin über `id` + Keys, nicht über den Namen.
    if ( !function_exists( 'qsfw_fs' ) ) {
        function qsfw_fs() {
            global $qsfw_fs;
            if ( !isset( $qsfw_fs ) ) {
                // Freemius SDK einbinden (gebündelt unter vendor/freemius).
                require_once dirname( __FILE__ ) . '/vendor/freemius/start.php';
                $qsfw_fs = fs_dynamic_init( array(
                    'id'               => '32194',
                    'slug'             => 'quickstock-for-woocommerce',
                    'type'             => 'plugin',
                    'public_key'       => 'pk_cc9696c8dc32d267573aed0615233',
                    'is_premium'       => false,
                    'premium_suffix'   => 'Pro',
                    'has_addons'       => false,
                    'has_paid_plans'   => true,
                    'is_org_compliant' => true,
                    'menu'             => array(
                        'slug'    => 'quickstock-for-woocommerce',
                        'parent'  => array(
                            'slug' => 'mlplugins-hub',
                        ),
                        'support' => false,
                    ),
                    'is_live'          => true,
                ) );
            }
            return $qsfw_fs;
        }

        // Freemius initialisieren.
        qsfw_fs();
        // Plugin-Icon für Aktivierungs-, Pricing- und vor allem die WordPress-
        // Update-Sektion setzen. Das Dashboard-Upload (300x300) allein reicht dafür
        // nicht – Freemius rendert das Update-Icon über diesen Filter (absoluter
        // Dateipfad, keine URL).
        qsfw_fs()->add_filter( 'plugin_icon', function () {
            return dirname( __FILE__ ) . '/assets/icon.png';
        } );
        // Aufräum-Logik bei vollständiger Deinstallation. Freemius verlangt das
        // statt einer eigenen uninstall.php (siehe qsfw_on_uninstall() weiter unten).
        qsfw_fs()->add_action( 'after_uninstall', 'qsfw_on_uninstall' );
        // Signalisieren, dass das SDK geladen wurde.
        do_action( 'qsfw_fs_loaded' );
    }
    // === Konstanten ===
    define( 'QSFW_VERSION', '5.13.22' );
    define( 'QSFW_PLUGIN_FILE', __FILE__ );
    define( 'QSFW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    define( 'QSFW_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
    // === Activation/Deactivation Hooks ===
    register_activation_hook( __FILE__, 'qsfw_on_activate' );
    function qsfw_on_activate() {
        update_option( 'qsfw_version', QSFW_VERSION );
        QSFW_Label_Templates::maybe_seed_default();
        QSFW_Log::create_table();
        if ( !wp_next_scheduled( QSFW_Log::CRON_HOOK ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', QSFW_Log::CRON_HOOK );
        }
    }

    register_deactivation_hook( __FILE__, 'qsfw_on_deactivate' );
    function qsfw_on_deactivate() {
        // Bei Deaktivierung NICHTS löschen!
        // Der User soll Daten behalten, falls er das Plugin nur kurz deaktiviert.
        // Aufräumen passiert nur bei Uninstall (qsfw_on_uninstall via Freemius).
        // Nur den Aufräum-Cron abmelden, damit er nicht ins Leere läuft.
        $ts = wp_next_scheduled( QSFW_Log::CRON_HOOK );
        if ( $ts ) {
            wp_unschedule_event( $ts, QSFW_Log::CRON_HOOK );
        }
    }

    // === Uninstall-Hook (via Freemius) ===
    // Freemius kapselt den Deinstallations-Prozess und verbietet eine eigene
    // uninstall.php neben dem SDK. Stattdessen registrieren wir die Aufräum-Logik
    // am `after_uninstall`-Hook. Läuft NUR bei vollständiger Deinstallation des
    // Plugins, nicht bei Deaktivierung. Die Registrierung erfolgt direkt nach der
    // Freemius-Init weiter unten via qsfw_fs()->add_action('after_uninstall', ...).
    function qsfw_on_uninstall() {
        global $wpdb;
        // === 0. Schutz: Noch eine ZWEITE Kopie des Plugins aktiv? ===
        // Optionen (qsfw_view_presets, qsfw_enabled_cols …) und die Tabellen
        // (Einkaufsbestellungen, Bestands-Verlauf …) sind GLOBAL – sie hängen NICHT
        // am Plugin-Ordner. Waren zwei Kopien desselben Plugins installiert und man
        // löscht die eine, würde dieses Aufräumen die Daten der noch aktiven zweiten
        // Kopie mitreissen (Einkaufsbestellungen, Presets, Spalten alle weg).
        // Darum: Ist noch irgendeine Kopie mit gleichem Haupt-Dateinamen aktiv,
        // brechen wir hier komplett ab und rühren nichts an.
        if ( qsfw_other_copy_active() ) {
            return;
        }
        // === 1. Globale Optionen löschen ===
        $qsfw_options = array(
            'qsfw_stock_checks',
            'qsfw_enabled_cols',
            'qsfw_view_presets',
            'qsfw_preset_defaults',
            'qsfw_version',
            'qsfw_col_overrides',
            'qsfw_done_reset_on_restock',
            'qsfw_bulk_chunk_size',
            'qsfw_log_db_version'
        );
        foreach ( $qsfw_options as $opt ) {
            delete_option( $opt );
        }
        // Falls Multisite: in jeder Site löschen
        if ( is_multisite() ) {
            $sites = get_sites( array(
                'fields' => 'ids',
            ) );
            foreach ( $sites as $site_id ) {
                switch_to_blog( $site_id );
                foreach ( $qsfw_options as $opt ) {
                    delete_option( $opt );
                }
                restore_current_blog();
            }
        }
        // === 2. Alle User-Meta löschen (für ALLE User auf einmal) ===
        $user_meta_keys = array(
            'qsfw_per_page',
            'qsfw_status_filter',
            'qsfw_mode',
            'qsfw_visible_cols',
            'qsfw_col_order',
            'qsfw_sort_reorder',
            'qsfw_sort_all',
            'qsfw_active_preset_all',
            'qsfw_active_preset_reorder',
            'qsfw_preset_last_mode',
            // Erweiterte Filter & Spalten-Wertfilter (pro Tab + Legacy-Globalkeys)
            'qsfw_adv_filters',
            'qsfw_adv_filters_all',
            'qsfw_adv_filters_reorder',
            'qsfw_col_filters',
            'qsfw_col_filters_all',
            'qsfw_col_filters_reorder',
        );
        foreach ( $user_meta_keys as $key ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Deinstallations-Cleanup; geloeschte User-Metadaten duerfen nicht gecacht werden
            $wpdb->delete( $wpdb->usermeta, array(
                'meta_key' => $key,
            ) );
        }
        // === 3. Custom Post-Meta NICHT löschen ===
        // Alle via Custom Fields gespeicherten Produktdaten (hb_einkaufspreis, hb_ek_link,
        // qsfw_cf_* etc.) sind echte Geschäftsdaten. Die behält der User auch ohne Plugin.
        // Falls jemand die wirklich loswerden will → manuell via SQL.
        // === 4. Transients aufräumen (falls wir später welche nutzen) ===
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- gezieltes Deinstallations-Cleanup eigener Transients
        $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_qsfw_%'" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- gezieltes Deinstallations-Cleanup eigener Transient-Timeouts
        $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_qsfw_%'" );
        // === 5. Bestands-Verlauf: Log-Tabelle löschen + Aufräum-Cron abmelden ===
        wp_clear_scheduled_hook( 'qsfw_log_cleanup' );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- eigene Tabelle wird bei der Deinstallation bewusst entfernt
        $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}qsfw_stock_log" );
        if ( is_multisite() ) {
            $sites = get_sites( array(
                'fields' => 'ids',
            ) );
            foreach ( $sites as $site_id ) {
                switch_to_blog( $site_id );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- eigene Tabelle wird pro Site bewusst entfernt
                $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}qsfw_stock_log" );
                restore_current_blog();
            }
        }
    }

    // Prüft, ob NEBEN der gerade zu löschenden noch eine weitere Kopie desselben
    // Plugins aktiv ist. Beim Uninstall ist die gelöschte Kopie bereits deaktiviert,
    // steht also nicht mehr in der Aktiv-Liste – ein Treffer heisst daher: es gibt
    // mindestens eine zweite, noch aktive Kopie. Verglichen wird der Haupt-Dateiname
    // (`quickstock-for-woocommerce.php`), unabhängig vom Ordnernamen.
    function qsfw_other_copy_active() {
        $main_file = basename( __FILE__ );
        // quickstock-for-woocommerce.php
        $active = (array) get_option( 'active_plugins', array() );
        // Multisite: netzwerkweit aktivierte Plugins mit einbeziehen.
        if ( is_multisite() ) {
            $network = (array) get_site_option( 'active_sitewide_plugins', array() );
            $active = array_merge( $active, array_keys( $network ) );
        }
        foreach ( $active as $plugin ) {
            if ( basename( $plugin ) === $main_file ) {
                return true;
            }
        }
        return false;
    }

    // === i18n: Textdomain laden ===
    add_action( 'init', 'qsfw_load_textdomain' );
    function qsfw_load_textdomain() {
        // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- fuer das Pro-Paket benoetigt, das nicht ueber translate.wordpress.org ausgeliefert wird
        load_plugin_textdomain( 'quickstock-for-woocommerce', false, dirname( plugin_basename( __FILE__ ) ) . '/languages/bundled' );
    }

    require_once QSFW_PLUGIN_DIR . 'includes/class-columns.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-filters.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-label-templates.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-presets.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-data.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-log.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-renderer.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-ajax.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-settings-page.php';
    require_once QSFW_PLUGIN_DIR . 'includes/class-admin-page.php';
    // === HPOS-Detection ===
    function qsfw_is_hpos_enabled() {
        if ( class_exists( '\\Automattic\\WooCommerce\\Utilities\\OrderUtil' ) ) {
            return \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
        }
        return false;
    }

    // === Cost of Goods Sold (COGS) Feature-Detection ===
    // Nativer WooCommerce-Einkaufspreis (ab WC 9.5). Optional aktivierbar unter
    // WooCommerce → Einstellungen → Erweitert → Funktionen. Defensiv geprüft, damit
    // das Plugin auch auf älteren WC-Versionen ohne COGS sauber lädt.
    function qsfw_cogs_enabled() {
        if ( !function_exists( 'wc_get_container' ) ) {
            return false;
        }
        $cls = '\\Automattic\\WooCommerce\\Internal\\CostOfGoodsSold\\CostOfGoodsSoldController';
        if ( !class_exists( $cls ) ) {
            return false;
        }
        try {
            return (bool) wc_get_container()->get( $cls )->feature_is_enabled();
        } catch ( \Throwable $e ) {
            return false;
        }
    }

    // === Free/Pro: Zentrale Capability-Schicht ===
    // Der Premium-Aufruf wird im von Freemius erzeugten WP.org-Paket
    // vollständig entfernt. Dort bleibt deshalb nur `return false` zurück und es
    // existiert kein lokaler Weg, Premium-Code per Lizenz freizuschalten.
    function qsfw_is_pro() {
        return false;
    }

    // Etiketten-Vorlagen und Custom Fields sind in beiden Ausgaben unbegrenzt.
    // Quoten, die erst gegen Bezahlung aufgehoben werden, gelten bei WordPress.org
    // als Trialware. Der Helper bleibt aus Rückwärtskompatibilität bestehen.
    function qsfw_limit(  $key  ) {
        return PHP_INT_MAX;
    }

    // Benannte Feature-Gates. Alle Pro-Features mappen vorerst direkt auf
    // `qsfw_is_pro()`; einzelne Gates lassen sich über `qsfw_can_use_<feature>`
    // gezielt überschreiben. Bekannte Features: po, reorder_pdf, csv_import,
    // suppliers, cogs, no_label, variation_bulk.
    function qsfw_can_use(  $feature  ) {
        $allowed = qsfw_is_pro();
        return (bool) apply_filters( 'qsfw_can_use_' . $feature, $allowed, $feature );
    }

    // Upgrade-/Checkout-URL für die „💎 Pro"-Nudges (Renderer::pro_upgrade_url()).
    // Sobald Freemius angebunden ist, liefert das SDK die echte Pricing-URL.
    add_filter( 'qsfw_upgrade_url', 'qsfw_freemius_upgrade_url' );
    function qsfw_freemius_upgrade_url(  $url  ) {
        if ( function_exists( 'qsfw_fs' ) ) {
            return qsfw_fs()->get_upgrade_url();
        }
        return $url;
    }

    // === Ansicht-Presets: Export/Import ===
    // Vor jeglichem Output ausgeführt, damit Datei-Download (header/exit)
    // und Redirect-nach-Import sauber funktionieren.
    add_action( 'admin_init', array('QSFW_Presets', 'maybe_handle_export_import') );
    // Preset-Editor (POST) und Preset-Link-Aktionen (anwenden/löschen/Standard).
    // Via admin_init, damit die Nonce-Prüfung und der PRG-Redirect vor jeglichem
    // Output greifen.
    add_action( 'admin_init', array('QSFW_Presets', 'maybe_handle_settings_post') );
    add_action( 'admin_init', array('QSFW_Presets', 'maybe_handle_tab_actions') );
    // Cockpit-Formular (Spalten, Reihenfolge, Sortierung, Seitengrösse, Status,
    // erweiterte Filter) per POST entgegennehmen, nonce-prüfen, in user_meta
    // speichern und auf eine saubere GET-URL umleiten (Post/Redirect/Get).
    add_action( 'admin_init', array('QSFW_Columns', 'maybe_handle_view_post') );
    // Spalten-Wertfilter per POST entgegennehmen und auf saubere URL umleiten
    // (Post/Redirect/Get) – vermeidet „414 Request-URI Too Large" bei vielen Werten.
    add_action( 'admin_init', array('QSFW_Filters', 'maybe_handle_colf_post') );
    // „Filter zurücksetzen" – löscht erweiterte Filter, Spalten-Wertfilter und
    // Status-Filter und leitet auf eine saubere mode-URL um.
    add_action( 'admin_init', array('QSFW_Filters', 'maybe_handle_reset') );
    // === Bestands-Verlauf / Log ===
    // Tabelle + Aufräum-Cron sicherstellen (greift auch bei bereits aktivem Plugin).
    add_action( 'admin_init', array('QSFW_Log', 'maybe_install') );
    // Aufräum-Cron: alte Einträge gemäß Aufbewahrungsdauer löschen.
    add_action( QSFW_Log::CRON_HOOK, array('QSFW_Log', 'cleanup') );
    // Bestellungen: Bestandsabbau und Rückbuchung protokollieren.
    add_action(
        'woocommerce_reduce_order_item_stock',
        array('QSFW_Log', 'on_order_reduce'),
        10,
        3
    );
    add_action(
        'woocommerce_restore_order_item_stock',
        array('QSFW_Log', 'on_order_restore'),
        10,
        4
    );
    // === Admin-Menü registrieren ===
    add_action( 'admin_menu', 'qsfw_register_menu', 99 );
    function qsfw_register_menu() {
        // Gemeinsames Hauptmenü „MLPlugins" sicherstellen (siehe unten).
        qsfw_register_mlplugins_menu();
        // QuickStock als Untermenü unter das gemeinsame Hauptmenü hängen.
        // „QuickStock" ist – wie „MLPlugins" – ein Markenname und wird NICHT
        // übersetzt (kein __()).
        add_submenu_page(
            'mlplugins-hub',
            'QuickStock',
            'QuickStock',
            'manage_woocommerce',
            'quickstock-for-woocommerce',
            array('QSFW_Admin_Page', 'render')
        );
    }

    /**
     * Gemeinsames Hauptmenü „MLPlugins".
     *
     * Mehrere ML-Plugins (QuickStock + kommende Plugins) sollen sich EIN Top-Level-
     * Menü teilen, statt jeweils das WooCommerce-Menü mit eigenen Einträgen zu
     * füllen. Da es unabhängige Plugins sind, darf nur EINES das Menü tatsächlich
     * anlegen – wer zuerst kommt, registriert es; die anderen hängen sich nur noch
     * per add_submenu_page('mlplugins-hub', …) dran. Der Guard über $admin_page_hooks
     * verhindert die Doppel-Registrierung. Künftige ML-Plugins nutzen exakt denselben
     * Guard + Slug `mlplugins-hub` (inkl. der beiden Aufräum-/Redirect-Hooks unten).
     *
     * Der Slug trug bis 5.13.15 den Namen `mlplugins`; das wp.org-Review verlangte
     * einen längeren, herstellerspezifischen Slug (Kollisionsrisiko).
     *
     * Der Hub ist reiner Laufzeit-Code (nichts in der DB) und hat bewusst KEINE
     * eigene Seite (leerer Callback): Der Menüpunkt ist nur ein Container, der Klick
     * öffnet direkt das erste eingehängte Tool. Deshalb existiert der Hub auch nur,
     * solange mindestens ein MLPlugins-Plugin aktiv ist – verwaiste Hauptmenüs oder
     * ein Uninstall-Cleanup sind damit gegenstandslos.
     *
     * „MLPlugins" ist ein Markenname und wird NICHT übersetzt (kein __()).
     */
    function qsfw_register_mlplugins_menu() {
        global $admin_page_hooks;
        // Ein anderes ML-Plugin (oder ein früherer Aufruf) hat das Menü schon angelegt.
        if ( isset( $admin_page_hooks['mlplugins-hub'] ) ) {
            return;
        }
        add_menu_page(
            'MLPlugins',
            'MLPlugins',
            'manage_woocommerce',
            'mlplugins-hub',
            '',
            // Kein eigener Callback – der Hub ist nur ein Container (siehe Docblock).
            qsfw_mlplugins_menu_icon(),
            58
        );
    }

    /**
     * Den von WordPress automatisch erzeugten Doppel-Untereintrag entfernen.
     *
     * Sobald das erste Submenü an ein Top-Level-Menü gehängt wird, kopiert WordPress
     * den Parent als ersten Untereintrag (gleicher Titel „MLPlugins", Slug `mlplugins-hub`).
     * Dieser Eintrag hat keine Funktion – wir entfernen ihn. Danach verlinkt der
     * Hauptmenü-Klick automatisch auf das erste echte Tool (WP nutzt den ersten
     * verbleibenden Untereintrag als Ziel). Läuft spät (Prio 999), damit alle Plugins
     * ihre Submenüs schon registriert haben.
     */
    add_action( 'admin_menu', 'qsfw_cleanup_mlplugins_menu', 999 );
    function qsfw_cleanup_mlplugins_menu() {
        remove_submenu_page( 'mlplugins-hub', 'mlplugins-hub' );
    }

    /**
     * Sicherheitsnetz: Ein direkter Aufruf von admin.php?page=mlplugins-hub (Lesezeichen,
     * getippte URL) würde eine leere Seite zeigen, da der Hub keinen eigenen Callback
     * hat. Wir leiten stattdessen auf das erste eingehängte Tool um. Läuft auf
     * admin_init (nach admin_menu), also sind alle Submenüs bereits bekannt.
     *
     * Der alte Slug `mlplugins` (bis 5.13.15) wird weiterhin abgefangen: Lesezeichen
     * bestehender Installationen sollen nicht auf ein „Cannot load"-wp_die() laufen.
     * Das funktioniert, weil admin.php erst admin_menu, dann admin_init und erst
     * danach die Seitenauflösung ausführt – wir sind also vor dem wp_die() dran.
     */
    add_action( 'admin_init', 'qsfw_redirect_mlplugins_hub' );
    function qsfw_redirect_mlplugins_hub() {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reiner GET-Router ohne Zustandsaenderung
        $page = ( isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '' );
        if ( !is_admin() || $page !== 'mlplugins-hub' && $page !== 'mlplugins' ) {
            return;
        }
        global $submenu;
        // Nachgeschlagen wird immer der neue Slug – `mlplugins` ist nach der
        // Umbenennung kein registriertes Menü mehr.
        if ( empty( $submenu['mlplugins-hub'] ) ) {
            return;
        }
        // Erstes echtes Tool suchen (den Parent-Slug selbst überspringen – falls der
        // Doppel-Eintrag ausnahmsweise noch da ist, würde er sonst eine Endlosschleife
        // erzeugen).
        foreach ( $submenu['mlplugins-hub'] as $item ) {
            if ( !empty( $item[2] ) && $item[2] !== 'mlplugins-hub' ) {
                wp_safe_redirect( admin_url( 'admin.php?page=' . $item[2] ) );
                exit;
            }
        }
    }

    /**
     * Menü-Icon für das „MLPlugins"-Hauptmenü als base64-Data-URI.
     *
     * Quelle: assets/images/mlplugins-menu-icon.svg. Wichtig: Die SVG MUSS ihre
     * Formen über `fill` einfärben (keine `stroke`/`fill="none"`-Konturen). WordPress
     * färbt Data-URI-Menü-Icons über wp-admin/js/svg-painter.js passend zum Farb-
     * schema um – der Painter ersetzt aber ausschliesslich `fill`-Werte. Stroke-
     * Konturen bleiben grau, und `fill="none"`-Flächen werden fälschlich gefüllt.
     * Fällt bei fehlender Datei auf ein Dashicon zurück, damit das Menü nie ohne
     * Icon dasteht.
     */
    function qsfw_mlplugins_menu_icon() {
        static $data_uri = null;
        if ( $data_uri !== null ) {
            return $data_uri;
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- liest ausschliesslich ein festes lokales Plugin-Asset; wp_remote_get() ist fuer Dateisystempfade ungeeignet
        $svg = @file_get_contents( QSFW_PLUGIN_DIR . 'assets/images/mlplugins-menu-icon.svg' );
        $data_uri = ( $svg ? 'data:image/svg+xml;base64,' . base64_encode( $svg ) : 'dashicons-screenoptions' );
        return $data_uri;
    }

    // === Body-Klassen für Density / Sticky-Header auf der Plugin-Seite ===
    add_filter( 'admin_body_class', 'qsfw_admin_body_class' );
    function qsfw_admin_body_class(  $classes  ) {
        $screen = ( function_exists( 'get_current_screen' ) ? get_current_screen() : null );
        if ( !$screen || strpos( (string) $screen->id, 'quickstock-for-woocommerce' ) === false ) {
            return $classes;
        }
        $density = QSFW_Settings_Page::get_general_setting( 'row_density', 'normal' );
        $sticky_h = (int) QSFW_Settings_Page::get_general_setting( 'sticky_header', 1 );
        $extra = ' qsfw-density-' . sanitize_html_class( $density );
        if ( $sticky_h ) {
            $extra .= ' qsfw-sticky-header';
        }
        return $classes . $extra;
    }

    // === Assets (CSS/JS) nur auf Plugin-Seite laden ===
    add_action( 'admin_enqueue_scripts', 'qsfw_enqueue_assets' );
    function qsfw_enqueue_assets(  $hook  ) {
        if ( strpos( $hook, 'quickstock-for-woocommerce' ) === false ) {
            return;
        }
        wp_enqueue_script( 'selectWoo' );
        // SelectWoo/Select2-CSS explizit laden — WooCommerce registriert das
        // 'select2'-Style-Handle auf fremden Admin-Seiten nicht zuverlässig,
        // dann bleibt das native <select> sichtbar neben dem SelectWoo-Widget.
        if ( defined( 'WC_PLUGIN_FILE' ) ) {
            wp_enqueue_style(
                'qsfw-select2',
                plugins_url( 'assets/css/select2.css', WC_PLUGIN_FILE ),
                array(),
                ( defined( 'WC_VERSION' ) ? WC_VERSION : null )
            );
        } else {
            wp_enqueue_style( 'select2' );
        }
        wp_enqueue_media();
        wp_enqueue_style(
            'qsfw-admin',
            QSFW_PLUGIN_URL . 'assets/css/admin.css',
            array(),
            filemtime( QSFW_PLUGIN_DIR . 'assets/css/admin.css' )
        );
        wp_enqueue_script(
            'jsbarcode',
            QSFW_PLUGIN_URL . 'assets/js/jsbarcode.js',
            array(),
            '3.12.3',
            true
        );
        wp_enqueue_script(
            'sortablejs',
            QSFW_PLUGIN_URL . 'assets/js/sortable.js',
            array(),
            '1.15.7',
            true
        );
        wp_enqueue_script(
            'qsfw-admin',
            QSFW_PLUGIN_URL . 'assets/js/admin.js',
            array(
                'jquery',
                'jsbarcode',
                'sortablejs',
                'wp-i18n'
            ),
            filemtime( QSFW_PLUGIN_DIR . 'assets/js/admin.js' ),
            true
        );
        wp_set_script_translations( 'qsfw-admin', 'quickstock-for-woocommerce', QSFW_PLUGIN_DIR . 'languages/bundled' );
        // Verhalten der Einstellungsseite (frueher Inline-<script> in
        // class-settings-page.php). Nur auf dem Settings-Tab noetig; die IIFEs
        // sind zwar per Element-Existenzpruefung abgesichert, aber so bleibt der
        // Footprint auf den uebrigen Tabs bei null.
        if ( QSFW_Columns::get_mode() === 'settings' ) {
            wp_enqueue_script(
                'qsfw-settings',
                QSFW_PLUGIN_URL . 'assets/js/settings.js',
                array(),
                filemtime( QSFW_PLUGIN_DIR . 'assets/js/settings.js' ),
                true
            );
        }
        // Etiketten-Vorlagen sicherstellen (auch wenn Plugin schon aktiv war)
        QSFW_Label_Templates::maybe_seed_default();
        // Term-Listen für die Bulk-Wertfelder
        $term_list = function ( $taxonomy ) {
            $out = array();
            $terms = get_terms( array(
                'taxonomy'   => $taxonomy,
                'hide_empty' => false,
            ) );
            if ( !is_wp_error( $terms ) && is_array( $terms ) ) {
                foreach ( $terms as $t ) {
                    // $t->name kommt roh aus get_terms() und enthält bereits
                    // HTML-Entities (z. B. "&amp;"). Einmal dekodieren, damit das JS
                    // (termOptions: $('<i>').text(name).html()) genau einmal escaped
                    // und Namen mit "&" nicht doppelt kodiert angezeigt werden.
                    $out[] = array(
                        'id'   => (int) $t->term_id,
                        'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ),
                    );
                }
            }
            return $out;
        };
        $bulk_terms = array();
        foreach ( QSFW_Columns::available_bulk_taxonomies() as $op => $m ) {
            $bulk_terms[$op] = $term_list( $m['tax'] );
        }
        // Custom-Field-Definitionen für die typgerechten Wertfelder
        $bulk_cf = array();
        // Phase-2-Settings für JS
        $p2_autosave = QSFW_Settings_Page::get_general_setting( 'autosave_mode', 'auto' );
        $p2_bc_mode = QSFW_Settings_Page::get_general_setting( 'bulk_confirm_mode', 'always' );
        $p2_bc_thresh = (int) QSFW_Settings_Page::get_general_setting( 'bulk_confirm_threshold', 50 );
        $p2_kbd = (int) QSFW_Settings_Page::get_general_setting( 'keyboard_shortcuts', 1 );
        $p2_cur_ovr = (int) QSFW_Settings_Page::get_general_setting( 'currency_override', 0 );
        $p2_cur_sym = ( $p2_cur_ovr ? QSFW_Settings_Page::get_general_setting( 'currency_symbol', '' ) : get_woocommerce_currency_symbol() );
        $p2_cur_pos = ( $p2_cur_ovr ? QSFW_Settings_Page::get_general_setting( 'currency_position', 'left' ) : get_option( 'woocommerce_currency_pos', 'left' ) );
        // Pro-Daten werden im Free-Build mitsamt diesem Block entfernt.
        $suppliers = array();
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- reine GET-Anzeigeparameter fuer die JavaScript-Konfiguration; keine Zustandsaenderung.
        $js_stock_start_date = ( isset( $_GET['stock_start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['stock_start_date'] ) ) : wp_date( 'Y-m-d', strtotime( '-14 days' ) ) );
        $js_category_filter = ( isset( $_GET['cat_filter'] ) ? intval( $_GET['cat_filter'] ) : 0 );
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
        // Daten an JS übergeben
        wp_localize_script( 'qsfw-admin', 'QSFW', array(
            'ajaxUrl'              => admin_url( 'admin-ajax.php' ),
            'nonce'                => wp_create_nonce( 'qsfw_stock_nonce' ),
            'viewNonce'            => wp_create_nonce( 'qsfw_view_state' ),
            'date'                 => $js_stock_start_date,
            'sort'                 => QSFW_Columns::get_sort( QSFW_Columns::get_mode() ),
            'mode'                 => QSFW_Columns::get_mode(),
            'category'             => $js_category_filter,
            'colFilters'           => QSFW_Filters::get_col_filters(),
            'emptyToken'           => QSFW_Filters::EMPTY_TOKEN,
            'labels'               => QSFW_Label_Templates::all(),
            'defaultLabel'         => QSFW_Label_Templates::get_default_id(),
            'bulkChunk'            => max( 1, min( 200, (int) get_option( 'qsfw_bulk_chunk_size', 25 ) ) ),
            'bulkTerms'            => $bulk_terms,
            'suppliers'            => $suppliers,
            'bulkCustomFields'     => $bulk_cf,
            'autosaveMode'         => $p2_autosave,
            'bulkConfirmMode'      => $p2_bc_mode,
            'bulkConfirmThreshold' => $p2_bc_thresh,
            'keyboardShortcuts'    => $p2_kbd,
            'currencySymbol'       => $p2_cur_sym,
            'currencyPosition'     => $p2_cur_pos,
        ) );
    }

}
// Ende else-Zweig des Doppel-Aktivierungs-Guards (siehe Dateianfang):
// Der komplette Plugin-Code oberhalb läuft nur in der zuerst geladenen Kopie.