<?php

/**
 * @package QuickStockForWooCommerce
 * @license GPL-2.0-or-later (https://www.gnu.org/licenses/gpl-2.0.html)
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
class QSFW_Data {
    public static function get_stock_data(
        $start_date,
        $sort_val = 'stock_asc',
        $mode = 'reorder',
        $statuses = array('publish'),
        $category_id = 0,
        $columns = array(),
        $filters = array(),
        $include_parents = false,
        $col_filters = null
    ) {
        global $wpdb;
        // Fallback für Produkte ohne eigene Nachbestellgrenze: WooCommerce-Standard.
        // Produkt-Override greift via COALESCE im SQL.
        $global_low_stock_amount = (int) get_option( 'woocommerce_notify_low_stock_amount', 2 );
        $order_sql = self::resolve_order_sql( $sort_val, (array) $columns );
        $allowed = array(
            'publish',
            'draft',
            'private',
            'pending'
        );
        $statuses = array_values( array_intersect( (array) $statuses, $allowed ) );
        if ( empty( $statuses ) ) {
            $statuses = array('publish');
        }
        $status_in = "'" . implode( "','", array_map( 'esc_sql', $statuses ) ) . "'";
        // Welche Spalten brauchen Extra-Daten?
        $cols = (array) $columns;
        // Die Bestellmengen-Spalte zeigt einen Vorschlag aus dem Zielbestand
        // (restock_target − Bestand). Dafür muss der Zielwert mitgeladen werden,
        // auch wenn die Restock-Spalte selbst nicht sichtbar ist.
        if ( in_array( 'order_qty', $cols, true ) && !in_array( 'restock_target', $cols, true ) ) {
            $cols[] = 'restock_target';
        }
        $extra_select = '';
        $extra_join = '';
        // Standard-Postmeta-Joins
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Feste JOIN-Konfiguration; die eigentlichen Meta-Abfragen werden unten vorbereitet und gezielt annotiert.
        $meta_joins = array(
            'backorders'       => array(
                'alias'    => 'bo',
                'meta_key' => '_backorders',
            ),
            'stock_status'     => array(
                'alias'    => 'ss',
                'meta_key' => '_stock_status',
            ),
            'sale_from'        => array(
                'alias'    => 'sf',
                'meta_key' => '_sale_price_dates_from',
            ),
            'sale_to'          => array(
                'alias'    => 'st',
                'meta_key' => '_sale_price_dates_to',
            ),
            'global_unique_id' => array(
                'alias'    => 'gid',
                'meta_key' => '_global_unique_id',
            ),
            'restock_target'   => array(
                'alias'    => 'rt',
                'meta_key' => '_qsfw_restock_target',
            ),
            'order_qty'        => array(
                'alias'    => 'oq',
                'meta_key' => '_qsfw_order_qty',
            ),
        );
        $meta_select_map = array(
            'backorders'       => 'backorders',
            'stock_status'     => 'stock_status',
            'sale_from'        => 'sale_from',
            'sale_to'          => 'sale_to',
            'global_unique_id' => 'global_unique_id',
            'restock_target'   => 'restock_target',
            'order_qty'        => 'order_qty',
        );
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
        foreach ( $meta_joins as $col => $cfg ) {
            if ( in_array( $col, $cols, true ) ) {
                $extra_select .= ", {$cfg['alias']}.meta_value as {$meta_select_map[$col]}";
                $extra_join .= " LEFT JOIN {$wpdb->postmeta} {$cfg['alias']} ON p.ID = {$cfg['alias']}.post_id AND {$cfg['alias']}.meta_key = '{$cfg['meta_key']}'";
            }
        }
        // Custom Fields (dynamisch)
        $cf_joins = array(
            'select' => '',
            'join'   => '',
        );
        $extra_select .= $cf_joins['select'];
        $extra_join .= $cf_joins['join'];
        // Taxonomie-Spalten (Subqueries für ID + Name)
        if ( in_array( 'shipping_class', $cols, true ) ) {
            $extra_select .= ", (SELECT tt_sc.term_id FROM {$wpdb->term_relationships} tr_sc\n                                  INNER JOIN {$wpdb->term_taxonomy} tt_sc ON tr_sc.term_taxonomy_id = tt_sc.term_taxonomy_id\n                                  WHERE tr_sc.object_id = p.ID AND tt_sc.taxonomy = 'product_shipping_class' LIMIT 1) as shipping_class_id";
        }
        // Inhalt / Permalink: direkt aus posts-Tabelle
        $post_extra = '';
        if ( in_array( 'excerpt', $cols, true ) ) {
            $post_extra .= ', p.post_excerpt';
        }
        if ( in_array( 'content', $cols, true ) ) {
            $post_extra .= ', p.post_content';
        }
        if ( in_array( 'permalink', $cols, true ) ) {
            $post_extra .= ', p.post_name';
        }
        if ( in_array( 'post_status', $cols, true ) ) {
            $post_extra .= ', p.post_status as post_status_val';
        }
        // Spalten-Wertfilter (Excel-Style) – pro Tab gespeicherter Benutzer-Zustand,
        // wird hier mitgezogen, falls der Aufrufer nichts übergibt. An den abgefragten
        // Modus gebunden, damit "Alle Produkte" und "Nachbestellung" eigene Filter haben.
        if ( $col_filters === null ) {
            $col_filters = QSFW_Filters::get_col_filters( $mode );
        }
        // Erweiterte Filter → WHERE / HAVING / zusätzliche JOINs
        $flt = self::build_filter_sql(
            (array) $filters,
            $cols,
            $extra_join,
            (array) $col_filters
        );
        $flt_where = ( empty( $flt['where'] ) ? '' : ' AND ' . implode( ' AND ', $flt['where'] ) );
        $flt_having = $flt['having'];
        // WHERE-Parameter vor HAVING-Parameter – exakt in Platzhalter-Reihenfolge
        // des zusammengesetzten SQL (WHERE-Block steht vor dem HAVING-Block).
        $flt_params = array_merge( $flt['where_params'], $flt['having_params'] );
        // === MODUS: ALLE PRODUKTE ===
        if ( $mode === 'all' ) {
            // %d bleibt im Fragment stehen und wird erst vom prepare() unten
            // gefüllt (der Parameter reiht sich in $args ein). Ein hier schon
            // gepreptes Fragment liefe weiter unten ein zweites Mal durch
            // prepare() -- unkritisch, solange intval() nur Ziffern übriglässt,
            // aber unnötig fragil.
            $cat_join = '';
            $cat_params = array();
            $category_id = intval( $category_id );
            if ( $category_id > 0 ) {
                $cat_join = "INNER JOIN {$wpdb->term_relationships} tr\n                        ON tr.object_id = IF(p.post_type='product_variation', p.post_parent, p.ID)\n                     INNER JOIN {$wpdb->term_taxonomy} tt\n                        ON tt.term_taxonomy_id = tr.term_taxonomy_id\n                        AND tt.taxonomy = 'product_cat'\n                        AND tt.term_id = %d";
                $cat_params[] = $category_id;
            }
            // Im Gruppierungs-Modus die Eltern variabler Produkte NICHT ausschliessen,
            // damit sie als aufklappbare Gruppen-Köpfe erscheinen (group_variations()).
            $exclude_parents = ( $include_parents ? '' : "\n                  AND NOT EXISTS (\n                      SELECT 1 FROM {$wpdb->posts} v\n                      WHERE v.post_parent = p.ID AND v.post_type = 'product_variation'\n                  )" );
            $sql = "\n                SELECT DISTINCT p.ID, p.post_type as post_type, p.post_parent as post_parent, sku.meta_value as sku,\n                       IF(p.post_type = 'product_variation', CONCAT(parent.post_title, ' - ', p.post_title), p.post_title) as post_title,\n                       CAST(stock.meta_value AS SIGNED) as stock_qty,\n                       COALESCE(CAST(low_stock.meta_value AS SIGNED), %d) as threshold,\n                       reg_price.meta_value as regular_price,\n                       sale_price.meta_value as sale_price,\n                       ms.meta_value as manage_stock\n                       {$post_extra}\n                       {$extra_select}\n                FROM {$wpdb->posts} p\n                LEFT JOIN {$wpdb->posts} parent ON p.post_parent = parent.ID\n                {$cat_join}\n                LEFT JOIN {$wpdb->postmeta} stock      ON p.ID = stock.post_id      AND stock.meta_key = '_stock'\n                LEFT JOIN {$wpdb->postmeta} ms         ON p.ID = ms.post_id         AND ms.meta_key = '_manage_stock'\n                LEFT JOIN {$wpdb->postmeta} sku        ON p.ID = sku.post_id        AND sku.meta_key = '_sku'\n                LEFT JOIN {$wpdb->postmeta} low_stock  ON p.ID = low_stock.post_id  AND low_stock.meta_key = '_low_stock_amount'\n                LEFT JOIN {$wpdb->postmeta} reg_price  ON p.ID = reg_price.post_id  AND reg_price.meta_key = '_regular_price'\n                LEFT JOIN {$wpdb->postmeta} sale_price ON p.ID = sale_price.post_id AND sale_price.meta_key = '_sale_price'\n                {$extra_join}\n                WHERE p.post_type IN ('product', 'product_variation')\n                  AND p.post_status IN ({$status_in})\n                  {$exclude_parents}\n                  {$flt_where}\n                " . (( empty( $flt_having ) ? '' : 'HAVING ' . implode( ' AND ', $flt_having ) )) . "\n                ORDER BY {$order_sql}\n            ";
            // Parameter exakt in Platzhalter-Reihenfolge des SQL:
            // %d im SELECT (threshold) → %d im $cat_join → WHERE- → HAVING-Parameter.
            $args = array_merge( array($global_low_stock_amount), $cat_params, $flt_params );
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $sql ist aus Whitelist-Fragmenten gebaut, alle Request-Werte stecken als %d/%s/%f in $args; Bestandsdaten muessen aktuell sein
            return $wpdb->get_results( $wpdb->prepare( $sql, $args ) );
        }
        // === MODUS: NACHBESTELLUNG ===
        // Abfragereihenfolge: erst Bestellungen nach Datum filtern, dann Produkt-IDs
        // sammeln (Derived Table), dann Produktdaten dazu joinen. So skaliert die
        // Abfrage mit dem Zeitraum statt mit der gesamten Bestellhistorie.
        if ( qsfw_is_hpos_enabled() ) {
            $order_join = "INNER JOIN (\n                SELECT DISTINCT CAST(oim_s.meta_value AS UNSIGNED) AS sold_id\n                FROM {$wpdb->prefix}wc_orders o_s\n                INNER JOIN {$wpdb->prefix}woocommerce_order_items oi_s ON oi_s.order_id = o_s.id\n                INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim_s ON oim_s.order_item_id = oi_s.order_item_id\n                WHERE o_s.type = 'shop_order'\n                  AND o_s.status IN ('wc-processing', 'wc-completed')\n                  AND o_s.date_created_gmt >= %s\n                  AND oim_s.meta_key IN ('_product_id', '_variation_id')\n            ) sold ON sold.sold_id = p.ID";
            $date_param = get_gmt_from_date( $start_date . ' 00:00:00' );
        } else {
            $order_join = "INNER JOIN (\n                SELECT DISTINCT CAST(oim_s.meta_value AS UNSIGNED) AS sold_id\n                FROM {$wpdb->posts} o_s\n                INNER JOIN {$wpdb->prefix}woocommerce_order_items oi_s ON oi_s.order_id = o_s.ID\n                INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim_s ON oim_s.order_item_id = oi_s.order_item_id\n                WHERE o_s.post_type = 'shop_order'\n                  AND o_s.post_status IN ('wc-processing', 'wc-completed')\n                  AND o_s.post_date >= %s\n                  AND oim_s.meta_key IN ('_product_id', '_variation_id')\n            ) sold ON sold.sold_id = p.ID";
            $date_param = $start_date . ' 00:00:00';
        }
        // Produkte/Variationen ausblenden, die bereits in einer offenen oder
        // bestellten Einkaufsbestellung stehen („ist bestellt, nicht nochmal
        // flaggen"). Die Klausel enthält keine Platzhalter → prepare-sicher.
        $po_exclusion = '';
        // Variationen erben Stock/Manage-Stock vom Parent, falls am Parent
        // verwaltet wird (Variation ohne eigenes _stock / _manage_stock).
        $sql = "\n            SELECT DISTINCT p.ID, p.post_type as post_type, p.post_parent as post_parent, sku.meta_value as sku,\n                   IF(p.post_type = 'product_variation', CONCAT(parent.post_title, ' - ', p.post_title), p.post_title) as post_title,\n                   CAST(COALESCE(NULLIF(stock.meta_value, ''), stock_parent.meta_value) AS SIGNED) as stock_qty,\n                   COALESCE(CAST(NULLIF(low_stock.meta_value, '') AS SIGNED), CAST(NULLIF(low_stock_parent.meta_value, '') AS SIGNED), %d) as threshold,\n                   reg_price.meta_value as regular_price,\n                   sale_price.meta_value as sale_price,\n                   COALESCE(NULLIF(ms.meta_value, ''), ms_parent.meta_value) as manage_stock\n                   {$post_extra}\n                   {$extra_select}\n            FROM {$wpdb->posts} p\n            LEFT JOIN {$wpdb->posts} parent ON p.post_parent = parent.ID\n            {$order_join}\n            LEFT  JOIN {$wpdb->postmeta} stock         ON p.ID = stock.post_id            AND stock.meta_key = '_stock'\n            LEFT  JOIN {$wpdb->postmeta} stock_parent  ON p.post_parent = stock_parent.post_id AND stock_parent.meta_key = '_stock'\n            LEFT  JOIN {$wpdb->postmeta} ms            ON p.ID = ms.post_id               AND ms.meta_key = '_manage_stock'\n            LEFT  JOIN {$wpdb->postmeta} ms_parent     ON p.post_parent = ms_parent.post_id    AND ms_parent.meta_key = '_manage_stock'\n            LEFT  JOIN {$wpdb->postmeta} sku           ON p.ID = sku.post_id              AND sku.meta_key = '_sku'\n            LEFT  JOIN {$wpdb->postmeta} low_stock     ON p.ID = low_stock.post_id        AND low_stock.meta_key = '_low_stock_amount'\n            LEFT  JOIN {$wpdb->postmeta} low_stock_parent ON p.post_parent = low_stock_parent.post_id AND low_stock_parent.meta_key = '_low_stock_amount'\n            LEFT  JOIN {$wpdb->postmeta} reg_price     ON p.ID = reg_price.post_id        AND reg_price.meta_key = '_regular_price'\n            LEFT  JOIN {$wpdb->postmeta} sale_price    ON p.ID = sale_price.post_id       AND sale_price.meta_key = '_sale_price'\n            {$extra_join}\n            WHERE p.post_type IN ('product', 'product_variation')\n              AND p.post_status IN ({$status_in})\n              AND NOT EXISTS (\n                  SELECT 1 FROM {$wpdb->posts} v\n                  WHERE v.post_parent = p.ID AND v.post_type = 'product_variation'\n              )\n              {$po_exclusion}\n              {$flt_where}\n            GROUP BY p.ID HAVING stock_qty <= threshold" . (( empty( $flt_having ) ? '' : ' AND ' . implode( ' AND ', $flt_having ) )) . "\n            ORDER BY {$order_sql}\n        ";
        $args = array_merge( array($global_low_stock_amount, $date_param), $flt_params );
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $sql ist aus Whitelist-Fragmenten gebaut, alle Request-Werte stecken als %d/%s/%f in $args; Bestandsdaten muessen aktuell sein
        return $wpdb->get_results( $wpdb->prepare( $sql, $args ) );
    }

    /**
     * Wandelt die flache Ergebnisliste (einfache Produkte + variable Eltern +
     * Variationen) in eine gruppierte Top-Level-Liste um: jeder variable Eltern
     * bekommt seine Variationen als $row->qsfw_children angehängt.
     *
     * - Top-Level-Reihenfolge bleibt die SQL-Sortierung (nur Nicht-Variationen).
     * - Variationen, deren Eltern nicht im Ergebnis ist (z.B. per Status
     *   herausgefiltert), erscheinen als eigenständige flache Zeile am SQL-Platz.
     * - Bei variablen Eltern ohne eigene Bestandsverwaltung wird der Bestand als
     *   Summe der Variationen aggregiert ($row->qsfw_agg_stock, read-only Anzeige).
     *
     * @param array $results Rohe Zeilenobjekte aus get_stock_data(..., true).
     * @return array Geordnete Top-Level-Zeilen mit qsfw_*-Markierungen.
     */
    public static function group_variations( $results ) {
        $is_variation = static function ( $r ) {
            return isset( $r->post_type ) && $r->post_type === 'product_variation';
        };
        // Top-Level-IDs sammeln + Variationen nach Eltern bündeln (SQL-Reihenfolge).
        $top_ids = array();
        $children = array();
        foreach ( $results as $r ) {
            if ( !$is_variation( $r ) ) {
                $top_ids[(int) $r->ID] = true;
            }
        }
        foreach ( $results as $r ) {
            if ( !$is_variation( $r ) ) {
                continue;
            }
            $pid = (int) (( isset( $r->post_parent ) ? $r->post_parent : 0 ));
            if ( isset( $top_ids[$pid] ) ) {
                $children[$pid][] = $r;
            }
        }
        $out = array();
        foreach ( $results as $r ) {
            if ( $is_variation( $r ) ) {
                $pid = (int) (( isset( $r->post_parent ) ? $r->post_parent : 0 ));
                if ( isset( $top_ids[$pid] ) ) {
                    continue;
                }
                // unter dem Eltern ausgegeben
                $out[] = $r;
                // Waise → flache Zeile
                continue;
            }
            $id = (int) $r->ID;
            if ( empty( $children[$id] ) ) {
                $r->qsfw_is_parent = false;
                $r->qsfw_children = array();
                $out[] = $r;
                continue;
            }
            $kids = $children[$id];
            $r->qsfw_is_parent = true;
            $r->qsfw_children = $kids;
            // Aggregat-Bestand nur, wenn der Eltern den Bestand NICHT selbst verwaltet
            // (dann liegt die Verwaltung auf Varianten-Ebene → Summe der Kinder).
            $parent_manages = isset( $r->manage_stock ) && $r->manage_stock === 'yes';
            if ( $parent_manages ) {
                $r->qsfw_has_agg = false;
                $r->qsfw_agg_stock = null;
            } else {
                $sum = 0;
                $any = false;
                foreach ( $kids as $k ) {
                    if ( isset( $k->manage_stock ) && $k->manage_stock === 'yes' && $k->stock_qty !== null ) {
                        $sum += (int) $k->stock_qty;
                        $any = true;
                    }
                }
                $r->qsfw_has_agg = true;
                $r->qsfw_agg_stock = ( $any ? $sum : null );
            }
            $out[] = $r;
        }
        return $out;
    }

    /**
     * Baut die ORDER-BY-Klausel aus dem Sortierwert.
     *
     * Spalten-Sortierungen ("col__<key>__<dir>") werden auf einen SQL-Ausdruck
     * abgebildet. Manche Ausdrücke beziehen sich auf SELECT-Aliase, die nur
     * existieren, wenn die Spalte sichtbar ist ($cols); ist der Ausdruck nicht
     * auflösbar, fällt die Sortierung sauber auf den Default zurück. Key und
     * Richtung sind über QSFW_Columns validiert, es fließt kein Roh-Input ein.
     */
    private static function resolve_order_sql( $sort_val, array $cols ) {
        global $wpdb;
        $fallback = 'stock_qty ASC, p.ID ASC';
        if ( $sort_val === 'newest' ) {
            return 'p.post_date DESC, p.ID DESC';
        }
        if ( $sort_val === 'oldest' ) {
            return 'p.post_date ASC, p.ID ASC';
        }
        // Legacy / interner Wert aus dem Nachbestellungs-Modus
        if ( $sort_val === 'stock_asc' ) {
            return 'stock_qty ASC, p.ID ASC';
        }
        if ( $sort_val === 'stock_desc' ) {
            return 'stock_qty DESC, p.ID ASC';
        }
        $parsed = QSFW_Columns::parse_column_sort( $sort_val );
        if ( $parsed === null ) {
            return $fallback;
        }
        $key = $parsed['key'];
        $dir = ( $parsed['dir'] === 'desc' ? 'DESC' : 'ASC' );
        // Immer verfügbare Ausdrücke (Aliase stets im SELECT).
        $always = array(
            'sku'          => 'sku',
            'title'        => 'post_title',
            'stock'        => 'stock_qty',
            'limit'        => 'threshold',
            'price_reg'    => "CAST(NULLIF(regular_price,'') AS DECIMAL(14,4))",
            'price_sale'   => "CAST(NULLIF(sale_price,'') AS DECIMAL(14,4))",
            'manage_stock' => 'manage_stock',
        );
        if ( isset( $always[$key] ) ) {
            return $always[$key] . ' ' . $dir . ', p.ID ASC';
        }
        // Taxonomie-Spalten: alphabetisch nach Term-Name (immer auflösbar).
        $tax_map = array(
            'shipping_class' => 'product_shipping_class',
            'product_cat'    => 'product_cat',
        );
        if ( isset( $tax_map[$key] ) ) {
            $tax = $tax_map[$key];
            $sub = "(SELECT tn_ord.name\n                       FROM {$wpdb->term_relationships} tr_ord\n                       INNER JOIN {$wpdb->term_taxonomy} tt_ord ON tr_ord.term_taxonomy_id = tt_ord.term_taxonomy_id\n                       INNER JOIN {$wpdb->terms} tn_ord ON tn_ord.term_id = tt_ord.term_id\n                      WHERE tr_ord.object_id = IF(p.post_type='product_variation', p.post_parent, p.ID)\n                        AND tt_ord.taxonomy = '" . esc_sql( $tax ) . "'\n                      ORDER BY tn_ord.name LIMIT 1)";
            return $sub . ' ' . $dir . ', p.ID ASC';
        }
        // Ausdrücke, die einen optionalen SELECT-Alias benötigen.
        $needs_alias = array(
            'backorders'       => 'backorders',
            'stock_status'     => 'stock_status',
            'global_unique_id' => 'global_unique_id',
            'sale_from'        => "CAST(NULLIF(sale_from,'') AS UNSIGNED)",
            'sale_to'          => "CAST(NULLIF(sale_to,'') AS UNSIGNED)",
            'excerpt'          => 'post_excerpt',
            'content'          => 'post_content',
            'permalink'        => 'post_name',
            'post_status'      => 'post_status_val',
        );
        if ( isset( $needs_alias[$key] ) ) {
            return ( in_array( $key, $cols, true ) ? $needs_alias[$key] . ' ' . $dir . ', p.ID ASC' : $fallback );
        }
        return $fallback;
    }

    /**
     * Baut WHERE-/HAVING-Fragmente + Parameter aus den aktiven erweiterten
     * Filtern. Benötigte Postmeta-JOINs werden bei Bedarf an $extra_join
     * angehängt (dedupliziert gegen die wegen sichtbarer Spalten bereits
     * vorhandenen Joins).
     */
    private static function build_filter_sql(
        array $filters,
        array $cols,
        string &$extra_join,
        array $col_filters = array()
    ) : array {
        global $wpdb;
        $where = array();
        $having = array();
        $where_params = array();
        $having_params = array();
        if ( empty( $filters ) && empty( $col_filters ) ) {
            return array(
                'where'         => $where,
                'having'        => $having,
                'where_params'  => $where_params,
                'having_params' => $having_params,
            );
        }
        $defs = QSFW_Filters::available();
        // Postmeta-Joins für eingebaute Filterspalten (Alias => [meta_key, sichtbar-wenn-col])
        $meta_alias = array(
            'stock_status'     => array(
                'alias' => 'ss',
                'mk'    => '_stock_status',
            ),
            'backorders'       => array(
                'alias' => 'bo',
                'mk'    => '_backorders',
            ),
            'sale_from'        => array(
                'alias' => 'sf',
                'mk'    => '_sale_price_dates_from',
            ),
            'sale_to'          => array(
                'alias' => 'st',
                'mk'    => '_sale_price_dates_to',
            ),
            'global_unique_id' => array(
                'alias' => 'gid',
                'mk'    => '_global_unique_id',
            ),
        );
        $added = array();
        $ensure_meta_join = function ( $alias, $meta_key, $col ) use(
            &$extra_join,
            &$added,
            $cols,
            $wpdb
        ) {
            if ( in_array( $col, $cols, true ) ) {
                return;
            }
            // schon wegen Spalte gejoint
            if ( isset( $added[$alias] ) ) {
                return;
            }
            $added[$alias] = true;
            $extra_join .= " LEFT JOIN {$wpdb->postmeta} {$alias} ON p.ID = {$alias}.post_id AND {$alias}.meta_key = '" . esc_sql( $meta_key ) . "'";
        };
        $ensure_cf_join = function ( $slug, $meta_key ) use(
            &$extra_join,
            &$added,
            $cols,
            $wpdb
        ) {
            if ( in_array( $slug, $cols, true ) ) {
                return;
            }
            // build_custom_field_joins hat schon gejoint
            $a = esc_sql( $slug );
            if ( isset( $added['cf_' . $a] ) ) {
                return;
            }
            $added['cf_' . $a] = true;
            $extra_join .= " LEFT JOIN {$wpdb->postmeta} `{$a}` ON p.ID = `{$a}`.post_id AND `{$a}`.meta_key = '" . esc_sql( $meta_key ) . "'";
        };
        foreach ( $filters as $col => $val ) {
            if ( !isset( $defs[$col] ) ) {
                continue;
            }
            $def = $defs[$col];
            $kind = $def['kind'];
            $is_cf = !empty( $def['cf'] );
            if ( $kind === 'num_range' ) {
                if ( $col === 'stock' || $col === 'limit' ) {
                    $expr = ( $col === 'stock' ? 'stock_qty' : 'threshold' );
                    if ( isset( $val['min'] ) ) {
                        $having[] = "{$expr} >= %f";
                        $having_params[] = (float) $val['min'];
                    }
                    if ( isset( $val['max'] ) ) {
                        $having[] = "{$expr} <= %f";
                        $having_params[] = (float) $val['max'];
                    }
                    continue;
                }
                if ( $col === 'price_reg' ) {
                    $e = "CAST(NULLIF(reg_price.meta_value,'') AS DECIMAL(14,4))";
                } elseif ( $col === 'price_sale' ) {
                    $e = "CAST(NULLIF(sale_price.meta_value,'') AS DECIMAL(14,4))";
                } elseif ( $is_cf ) {
                    $ensure_cf_join( $col, $def['meta_key'] );
                    $a = esc_sql( $col );
                    $e = "CAST(NULLIF(`{$a}`.meta_value,'') AS DECIMAL(14,4))";
                } else {
                    continue;
                }
                if ( isset( $val['min'] ) ) {
                    $where[] = "{$e} >= %f";
                    $where_params[] = (float) $val['min'];
                }
                if ( isset( $val['max'] ) ) {
                    $where[] = "{$e} <= %f";
                    $where_params[] = (float) $val['max'];
                }
                continue;
            }
            if ( $kind === 'date_range' ) {
                if ( $is_cf ) {
                    $ensure_cf_join( $col, $def['meta_key'] );
                    $a = esc_sql( $col );
                    $e = "`{$a}`.meta_value";
                } elseif ( isset( $meta_alias[$col] ) ) {
                    $m = $meta_alias[$col];
                    $ensure_meta_join( $m['alias'], $m['mk'], $col );
                    $e = "DATE(FROM_UNIXTIME(NULLIF({$m['alias']}.meta_value,'')))";
                } else {
                    continue;
                }
                if ( isset( $val['min'] ) ) {
                    $where[] = "{$e} >= %s";
                    $where_params[] = $val['min'];
                }
                if ( isset( $val['max'] ) ) {
                    $where[] = "{$e} <= %s";
                    $where_params[] = $val['max'];
                }
                continue;
            }
            if ( $kind === 'enum' ) {
                if ( $col === 'manage_stock' ) {
                    $yes = in_array( 'yes', $val, true );
                    $no = in_array( 'no', $val, true );
                    if ( $yes && !$no ) {
                        $where[] = "ms.meta_value = 'yes'";
                    } elseif ( $no && !$yes ) {
                        $where[] = "(ms.meta_value IS NULL OR ms.meta_value <> 'yes')";
                    }
                    continue;
                }
                if ( $is_cf ) {
                    $ensure_cf_join( $col, $def['meta_key'] );
                    $a = esc_sql( $col );
                    $yes = in_array( 'yes', $val, true );
                    $no = in_array( 'no', $val, true );
                    if ( $yes && !$no ) {
                        $where[] = "`{$a}`.meta_value = 'yes'";
                    } elseif ( $no && !$yes ) {
                        $where[] = "(`{$a}`.meta_value IS NULL OR `{$a}`.meta_value <> 'yes')";
                    }
                    continue;
                }
                if ( !isset( $meta_alias[$col] ) ) {
                    continue;
                }
                $m = $meta_alias[$col];
                $ensure_meta_join( $m['alias'], $m['mk'], $col );
                $ph = implode( ',', array_fill( 0, count( $val ), '%s' ) );
                $clause = "{$m['alias']}.meta_value IN ({$ph})";
                foreach ( $val as $vv ) {
                    $where_params[] = $vv;
                }
                if ( $col === 'backorders' && in_array( 'no', $val, true ) ) {
                    $clause = "({$clause} OR {$m['alias']}.meta_value IS NULL OR {$m['alias']}.meta_value = '')";
                }
                $where[] = $clause;
                continue;
            }
            if ( $kind === 'text' ) {
                if ( $is_cf ) {
                    $ensure_cf_join( $col, $def['meta_key'] );
                    $a = esc_sql( $col );
                    $field = "`{$a}`.meta_value";
                } elseif ( isset( $meta_alias[$col] ) ) {
                    $m = $meta_alias[$col];
                    $ensure_meta_join( $m['alias'], $m['mk'], $col );
                    $field = "{$m['alias']}.meta_value";
                } else {
                    continue;
                }
                $where[] = "{$field} LIKE %s";
                $where_params[] = '%' . $wpdb->esc_like( (string) $val ) . '%';
                continue;
            }
            if ( $kind === 'terms' ) {
                $ids = array_map( 'intval', (array) $val );
                $ids = array_filter( $ids, function ( $i ) {
                    return $i > 0;
                } );
                if ( empty( $ids ) ) {
                    continue;
                }
                $sfx = preg_replace( '/[^A-Za-z0-9_]/', '', $col );
                $id_in = implode( ',', $ids );
                $where[] = "EXISTS (SELECT 1 FROM {$wpdb->term_relationships} fr_{$sfx}" . " INNER JOIN {$wpdb->term_taxonomy} ft_{$sfx}" . " ON fr_{$sfx}.term_taxonomy_id = ft_{$sfx}.term_taxonomy_id" . " WHERE fr_{$sfx}.object_id = IF(p.post_type='product_variation', p.post_parent, p.ID)" . " AND ft_{$sfx}.taxonomy = %s AND ft_{$sfx}.term_id IN ({$id_in}))";
                $where_params[] = $def['tax'];
                continue;
            }
            if ( $kind === 'terms_inc_exc' ) {
                if ( !is_array( $val ) || empty( $val['ids'] ) ) {
                    continue;
                }
                $ids = array_map( 'intval', $val['ids'] );
                $ids = array_filter( $ids, function ( $i ) {
                    return $i > 0;
                } );
                if ( empty( $ids ) ) {
                    continue;
                }
                $mode = ( isset( $val['mode'] ) && $val['mode'] === 'ex' ? 'ex' : 'in' );
                $sfx = preg_replace( '/[^A-Za-z0-9_]/', '', $col );
                $id_in = implode( ',', $ids );
                $sub = "SELECT 1 FROM {$wpdb->term_relationships} fr_{$sfx}" . " INNER JOIN {$wpdb->term_taxonomy} ft_{$sfx}" . " ON fr_{$sfx}.term_taxonomy_id = ft_{$sfx}.term_taxonomy_id" . " WHERE fr_{$sfx}.object_id = IF(p.post_type='product_variation', p.post_parent, p.ID)" . " AND ft_{$sfx}.taxonomy = %s AND ft_{$sfx}.term_id IN ({$id_in})";
                $where[] = ( $mode === 'ex' ? "NOT EXISTS ({$sub})" : "EXISTS ({$sub})" );
                $where_params[] = $def['tax'];
                continue;
            }
        }
        // --- Spalten-Wertfilter (Excel-Style) ---
        // Eigenständige EXISTS-/Direktbedingungen (nur WHERE, kein HAVING, keine
        // zusätzlichen Haupt-Joins). Werden hinter die adv-WHERE-Klauseln gehängt;
        // ihre Parameter landen entsprechend hinter den adv-WHERE-Parametern und
        // vor den HAVING-Parametern.
        if ( !empty( $col_filters ) ) {
            $cf_defs = QSFW_Filters::value_filterable();
            foreach ( $col_filters as $col => $val ) {
                if ( !isset( $cf_defs[$col] ) || !is_array( $val ) ) {
                    continue;
                }
                $cdef = $cf_defs[$col];
                $type = $cdef['type'];
                $sfx = preg_replace( '/[^A-Za-z0-9_]/', '', $col );
                // „Ist nicht leer": Kehrwert der (Leere)-Logik je Typ. Ein
                // einziger semantischer Ausdruck statt einer aufgezählten
                // Werteliste – greift damit über ALLE Werte, nicht nur die
                // im Dropdown geladenen (Deckelung irrelevant).
                if ( !empty( $val['nonempty'] ) ) {
                    if ( $type === 'tax' ) {
                        $obj = "IF(p.post_type='product_variation', p.post_parent, p.ID)";
                        $where[] = "EXISTS (SELECT 1 FROM {$wpdb->term_relationships} cfne_{$sfx}" . " INNER JOIN {$wpdb->term_taxonomy} cfnet_{$sfx}" . " ON cfne_{$sfx}.term_taxonomy_id = cfnet_{$sfx}.term_taxonomy_id" . " WHERE cfne_{$sfx}.object_id = {$obj} AND cfnet_{$sfx}.taxonomy = %s)";
                        $where_params[] = $cdef['tax'];
                    } elseif ( $type === 'post' ) {
                        $expr = $cdef['expr'];
                        // vertrauenswürdiger interner Ausdruck
                        $where[] = "({$expr} <> '' AND {$expr} IS NOT NULL)";
                    } else {
                        // meta / cf_meta / supplier → Postmeta mit nicht-leerem Wert
                        $mk = $cdef['meta_key'];
                        $where[] = "EXISTS (SELECT 1 FROM {$wpdb->postmeta} cfne_{$sfx}" . " WHERE cfne_{$sfx}.post_id = p.ID AND cfne_{$sfx}.meta_key = %s" . " AND cfne_{$sfx}.meta_value <> '')";
                        $where_params[] = $mk;
                    }
                    continue;
                }
                if ( $type === 'tax' ) {
                    $want_empty = false;
                    $ids = array();
                    foreach ( (array) ($val['in'] ?? array()) as $id ) {
                        if ( (string) $id === QSFW_Filters::EMPTY_TOKEN ) {
                            $want_empty = true;
                            continue;
                        }
                        $id = intval( $id );
                        if ( $id > 0 ) {
                            $ids[] = $id;
                        }
                    }
                    $ids = array_values( array_unique( $ids ) );
                    $obj = "IF(p.post_type='product_variation', p.post_parent, p.ID)";
                    $clauses = array();
                    if ( !empty( $ids ) ) {
                        $id_in = implode( ',', $ids );
                        $clauses[] = "EXISTS (SELECT 1 FROM {$wpdb->term_relationships} cfr_{$sfx}" . " INNER JOIN {$wpdb->term_taxonomy} cft_{$sfx}" . " ON cfr_{$sfx}.term_taxonomy_id = cft_{$sfx}.term_taxonomy_id" . " WHERE cfr_{$sfx}.object_id = {$obj}" . " AND cft_{$sfx}.taxonomy = %s AND cft_{$sfx}.term_id IN ({$id_in}))";
                        $where_params[] = $cdef['tax'];
                    }
                    if ( $want_empty ) {
                        // „(Leere)": Produkt (bzw. Parent der Variation) hat keinen Term dieser Taxonomie.
                        $clauses[] = "NOT EXISTS (SELECT 1 FROM {$wpdb->term_relationships} cfre_{$sfx}" . " INNER JOIN {$wpdb->term_taxonomy} cfte_{$sfx}" . " ON cfre_{$sfx}.term_taxonomy_id = cfte_{$sfx}.term_taxonomy_id" . " WHERE cfre_{$sfx}.object_id = {$obj}" . " AND cfte_{$sfx}.taxonomy = %s)";
                        $where_params[] = $cdef['tax'];
                    }
                    if ( empty( $clauses ) ) {
                        continue;
                    }
                    $where[] = '(' . implode( ' OR ', $clauses ) . ')';
                    continue;
                }
                if ( $type === 'post' ) {
                    $expr = $cdef['expr'];
                    // vertrauenswürdiger interner Ausdruck (p.post_title …)
                    if ( isset( $val['like'] ) ) {
                        $where[] = "{$expr} LIKE %s";
                        $where_params[] = '%' . $wpdb->esc_like( (string) $val['like'] ) . '%';
                    } elseif ( !empty( $val['in'] ) && is_array( $val['in'] ) ) {
                        $want_empty = false;
                        $vals = array();
                        foreach ( $val['in'] as $vv ) {
                            if ( (string) $vv === QSFW_Filters::EMPTY_TOKEN ) {
                                $want_empty = true;
                                continue;
                            }
                            $vals[] = (string) $vv;
                        }
                        $clauses = array();
                        if ( !empty( $vals ) ) {
                            $ph = implode( ',', array_fill( 0, count( $vals ), '%s' ) );
                            $clauses[] = "{$expr} IN ({$ph})";
                            foreach ( $vals as $vv ) {
                                $where_params[] = $vv;
                            }
                        }
                        if ( $want_empty ) {
                            $clauses[] = "({$expr} = '' OR {$expr} IS NULL)";
                        }
                        if ( !empty( $clauses ) ) {
                            $where[] = '(' . implode( ' OR ', $clauses ) . ')';
                        }
                    }
                    continue;
                }
                // 'meta' / 'cf_meta' → EXISTS auf postmeta
                $mk = $cdef['meta_key'];
                if ( isset( $val['like'] ) ) {
                    $where[] = "EXISTS (SELECT 1 FROM {$wpdb->postmeta} cfm_{$sfx}" . " WHERE cfm_{$sfx}.post_id = p.ID AND cfm_{$sfx}.meta_key = %s" . " AND cfm_{$sfx}.meta_value LIKE %s)";
                    $where_params[] = $mk;
                    $where_params[] = '%' . $wpdb->esc_like( (string) $val['like'] ) . '%';
                } elseif ( !empty( $val['in'] ) && is_array( $val['in'] ) ) {
                    $want_empty = false;
                    $vals = array();
                    foreach ( $val['in'] as $vv ) {
                        if ( (string) $vv === QSFW_Filters::EMPTY_TOKEN ) {
                            $want_empty = true;
                            continue;
                        }
                        $vals[] = (string) $vv;
                    }
                    $clauses = array();
                    if ( !empty( $vals ) ) {
                        $ph = implode( ',', array_fill( 0, count( $vals ), '%s' ) );
                        $clauses[] = "EXISTS (SELECT 1 FROM {$wpdb->postmeta} cfm_{$sfx}" . " WHERE cfm_{$sfx}.post_id = p.ID AND cfm_{$sfx}.meta_key = %s" . " AND cfm_{$sfx}.meta_value IN ({$ph}))";
                        $where_params[] = $mk;
                        foreach ( $vals as $vv ) {
                            $where_params[] = $vv;
                        }
                    }
                    if ( $want_empty ) {
                        // „(Leere)": kein Meta-Eintrag oder leerer Wert.
                        $clauses[] = "NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} cfme_{$sfx}" . " WHERE cfme_{$sfx}.post_id = p.ID AND cfme_{$sfx}.meta_key = %s" . " AND cfme_{$sfx}.meta_value <> '')";
                        $where_params[] = $mk;
                    }
                    if ( !empty( $clauses ) ) {
                        $where[] = '(' . implode( ' OR ', $clauses ) . ')';
                    }
                }
            }
        }
        return array(
            'where'         => $where,
            'having'        => $having,
            'where_params'  => $where_params,
            'having_params' => $having_params,
        );
    }

    /**
     * Distinct-Werte einer Spalte für das Excel-Style Wertfilter-Dropdown.
     * Optional per Teilstring ($search) serverseitig gefiltert, hart auf $limit
     * begrenzt. Quelle wird über QSFW_Filters::value_filterable() aufgelöst.
     *
     * @return array{values: array<int,array{value:string,label:string}>, capped: bool}
     */
    public static function distinct_column_values(
        string $col,
        string $search = '',
        string $mode = 'all',
        int $limit = 500
    ) : array {
        global $wpdb;
        $avail = QSFW_Filters::value_filterable();
        if ( !isset( $avail[$col] ) ) {
            return array(
                'values'    => array(),
                'capped'    => false,
                'has_empty' => false,
            );
        }
        $def = $avail[$col];
        $type = $def['type'];
        $search = trim( $search );
        $limit = max( 1, min( 2000, $limit ) );
        // „(Leere)"-Option nur bei der ungefilterten Erstanzeige prüfen –
        // bei aktiver Wertsuche ist sie (wie in Excel) ohnehin ausgeblendet.
        $has_empty = ( $search === '' ? self::column_has_empty( $def ) : false );
        // Taxonomie: Werteliste = Terms (value = term_id, label = Name).
        if ( $type === 'tax' ) {
            $args = array(
                'taxonomy'   => $def['tax'],
                'hide_empty' => false,
                'number'     => $limit + 1,
                'orderby'    => 'name',
            );
            if ( $search !== '' ) {
                $args['search'] = $search;
            }
            $terms = get_terms( $args );
            $values = array();
            if ( !is_wp_error( $terms ) ) {
                foreach ( $terms as $t ) {
                    $values[] = array(
                        'value' => (string) $t->term_id,
                        'label' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ),
                    );
                }
            }
            $capped = count( $values ) > $limit;
            if ( $capped ) {
                $values = array_slice( $values, 0, $limit );
            }
            return array(
                'values'    => $values,
                'capped'    => $capped,
                'has_empty' => $has_empty,
            );
        }
        $numeric = in_array( $col, array(
            'price_reg',
            'price_sale',
            'stock',
            'limit'
        ), true );
        if ( $type === 'post' ) {
            $expr = $def['expr'];
            // vertrauenswürdiger interner Ausdruck (p.post_title …)
            // Spaltenname ohne Tabellen-Alias: %i verträgt keinen Punkt, und die
            // Abfrage hat nur die Tabelle p, der bare Spaltenname ist eindeutig.
            $col_id = substr( $expr, strpos( $expr, '.' ) + 1 );
            $sql = "SELECT DISTINCT %i AS v FROM {$wpdb->posts} p\n                       WHERE p.post_type IN ('product','product_variation')\n                         AND p.post_status IN ('publish','draft','private','pending')\n                         AND %i <> ''";
            $args = array($col_id, $col_id);
            if ( $search !== '' ) {
                $sql .= " AND %i LIKE %s";
                $args[] = $col_id;
                $args[] = '%' . $wpdb->esc_like( $search ) . '%';
            }
            // Post-Spalten sind reine Textspalten (Titel, Permalink, Status …),
            // nie numerisch – daher direkte Sortierung ohne CAST.
            $sql .= " ORDER BY %i ASC LIMIT %d";
            $args[] = $col_id;
            $args[] = $limit + 1;
        } else {
            // 'meta' / 'cf_meta'
            $mk = $def['meta_key'];
            $sql = "SELECT DISTINCT pm.meta_value AS v\n                      FROM {$wpdb->postmeta} pm\n                      INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id\n                      WHERE pm.meta_key = %s\n                        AND p.post_type IN ('product','product_variation')\n                        AND p.post_status IN ('publish','draft','private','pending')\n                        AND pm.meta_value <> ''";
            $args = array($mk);
            if ( $search !== '' ) {
                $sql .= " AND pm.meta_value LIKE %s";
                $args[] = '%' . $wpdb->esc_like( $search ) . '%';
            }
            // Sortierspalte ist konstant (pm.meta_value) – als Literal eingesetzt,
            // numerische Spalten numerisch, sonst lexikografisch.
            $sql .= ( $numeric ? " ORDER BY CAST(NULLIF(pm.meta_value,'') AS DECIMAL(14,4)) ASC LIMIT %d" : " ORDER BY pm.meta_value ASC LIMIT %d" );
            $args[] = $limit + 1;
        }
        // $sql wird je nach Typ/Suche dynamisch zusammengesetzt und kann daher kein
        // Inline-Literal sein – der Sniff flaggt die Variable unabhängig vom Inhalt.
        // Sortierspalte/Ausdruck stammen aus der internen Whitelist bzw. sind über %i
        // gebunden, alle Nutzwerte stehen über %s/%d in $args.
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- s. o.; Filterwerte müssen aktuell sein
        $rows = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
        $capped = count( $rows ) > $limit;
        if ( $capped ) {
            $rows = array_slice( $rows, 0, $limit );
        }
        $values = array();
        foreach ( $rows as $r ) {
            $r = (string) $r;
            $values[] = array(
                'value' => $r,
                'label' => $r,
            );
        }
        return array(
            'values'    => $values,
            'capped'    => $capped,
            'has_empty' => $has_empty,
        );
    }

    /**
     * Prüft, ob die Spalte mindestens einen leeren Wert hat (für die
     * „(Leere)"-Option im Wertfilter-Dropdown). Spiegelt exakt die im
     * Filter-SQL verwendete Leer-Logik je Typ wider (siehe EMPTY_TOKEN in
     * build_filter_sql): Taxonomie ohne Term, Post-Ausdruck = '' / NULL,
     * Meta fehlend oder leer. EXISTS bricht beim ersten Treffer ab.
     */
    private static function column_has_empty( array $def ) : bool {
        global $wpdb;
        $type = $def['type'];
        if ( $type === 'tax' ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Leerwertpruefung muss aktuell sein; Taxonomie wird ueber %s gebunden
            return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT EXISTS(\n                SELECT 1 FROM {$wpdb->posts} p\n                 WHERE p.post_type IN ('product','product_variation') AND p.post_status IN ('publish','draft','private','pending')\n                   AND NOT EXISTS (\n                       SELECT 1 FROM {$wpdb->term_relationships} tr\n                         INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id\n                        WHERE tr.object_id = IF(p.post_type='product_variation', p.post_parent, p.ID)\n                          AND tt.taxonomy = %s))", $def['tax'] ) );
        }
        if ( $type === 'post' ) {
            $expr = $def['expr'];
            // vertrauenswürdiger interner Ausdruck (p.post_title …)
            // Spaltenname ohne Tabellen-Alias: %i verträgt keinen Punkt, und die
            // Abfrage hat nur die Tabelle p, der bare Spaltenname ist eindeutig.
            $col_id = substr( $expr, strpos( $expr, '.' ) + 1 );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Spalte über %i gebunden; Leerwertprüfung muss aktuell sein
            return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT EXISTS(\n                SELECT 1 FROM {$wpdb->posts} p\n                 WHERE p.post_type IN ('product','product_variation') AND p.post_status IN ('publish','draft','private','pending')\n                   AND (%i = '' OR %i IS NULL))", $col_id, $col_id ) );
        }
        // 'meta' / 'cf_meta' / 'supplier': leer = kein Meta-Eintrag ODER leerer Wert.
        $mk = $def['meta_key'];
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Leerwertpruefung muss aktuell sein; Meta-Key wird ueber %s gebunden
        return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT EXISTS(\n            SELECT 1 FROM {$wpdb->posts} p\n             WHERE p.post_type IN ('product','product_variation') AND p.post_status IN ('publish','draft','private','pending')\n               AND NOT EXISTS (\n                   SELECT 1 FROM {$wpdb->postmeta} pm\n                    WHERE pm.post_id = p.ID AND pm.meta_key = %s AND pm.meta_value <> ''))", $mk ) );
    }

    /**
     * Liefert kommagetrennte Kategorie-Namen für ein Produkt (oder dessen Parent bei Variationen).
     * Wird bei Bedarf gerufen, da pro Produkt eigene Query.
     */
    public static function get_product_categories( $product_id ) {
        $post = get_post( $product_id );
        if ( !$post ) {
            return '';
        }
        $target_id = ( $post->post_type === 'product_variation' ? $post->post_parent : $product_id );
        $terms = get_the_terms( $target_id, 'product_cat' );
        if ( is_wp_error( $terms ) || !is_array( $terms ) ) {
            return '';
        }
        // CSV ist kein HTML: Entities dekodieren (WP speichert "&" als "&amp;"),
        // damit "Vaporizer &amp; Zubehör" als "Vaporizer & Zubehör" exportiert wird.
        $names = array_map( static function ( $name ) {
            return html_entity_decode( $name, ENT_QUOTES, 'UTF-8' );
        }, wp_list_pluck( $terms, 'name' ) );
        return implode( ', ', $names );
    }

}
