<?php
defined('ABSPATH') || exit;

use Google\Protobuf\Timestamp;
use Google\Shopping\Merchant\Products\V1\AgeGroup;
use Google\Shopping\Merchant\Products\V1\Availability;
use Google\Shopping\Merchant\Products\V1\Condition;
use Google\Shopping\Merchant\Products\V1\Gender;
use Google\Shopping\Merchant\Products\V1\ProductAttributes;
use Google\Shopping\Merchant\Products\V1\ProductInput;
use Google\Shopping\Merchant\Products\V1\ShippingWeight;
use Google\Shopping\Type\Price;
use Google\Type\Interval;

/**
 * Maps a WooCommerce product to a Google Merchant API ProductInput object.
 *
 * Offer ID scheme (preserves continuity with the stock-app feed):
 *   offer_id      = 'SKU-{sku}'  or  'PID-{id}'  if no SKU
 *   item_group_id = 'SKU-{parent_sku}'  or  'PID-{parent_id}'  (variations only)
 *
 * Availability:
 *   composite / bundle       → always 'in stock'
 *   stock_quantity > 0       → 'in stock'
 *   stock_status=onbackorder → 'preorder'
 *   instock (unmanaged)      → 'in stock'
 *   otherwise                → 'out of stock'
 *
 * Availability date (only when not in stock):
 *   due_date meta  (YYYY-MM-DD)  → exact date
 *   lead_time meta (integer days) → now + N days
 *   Check variation first, then parent.
 *   These are pushed from the stock system via WooCommerce product meta.
 *
 * Attribute / term mappings are read from wp_options and the term-mappings table
 * once at construction time so per-product calls are cheap.
 */
class JRA_Merchant_Product_Mapper {

    private const GOOGLE_ATTRS = ['color', 'size', 'material', 'pattern', 'age_group', 'gender'];

    private array $settings;

    /** ['color' => 'pa_colour', 'size' => 'pa_size', ...] */
    private array $attrMappings;

    /** ['gender' => ['Mens' => 'male', 'Womens' => 'female'], ...] */
    private array $termMappings;

    private JRA_Merchant_Offer_Id_Map $offerIdMap;

    /** [wc_term_id => google_taxonomy_id_string] */
    private array $googleCategoryMap;

    /** [tag_slug => label_value] */
    private array $customLabelTagMap;

    /** [wc_term_id => label_value] */
    private array $customLabelCatMap;

    /** [child_product_id => grouped_parent_id] built once for efficient lookup */
    private array $groupedChildMap;

    public function __construct() {
        $this->settings          = (array) get_option('jra_merchant_settings', []);
        $this->attrMappings      = (array) get_option('jra_merchant_attr_mappings', []);
        $this->termMappings      = $this->loadTermMappings();
        $this->offerIdMap        = new JRA_Merchant_Offer_Id_Map();
        $this->googleCategoryMap = (array) get_option('jra_merchant_google_category_map', []);
        $this->customLabelTagMap = (array) get_option('jra_merchant_custom_label_tag_map', []);
        $this->customLabelCatMap = (array) get_option('jra_merchant_custom_label_cat_map', []);
        $this->groupedChildMap   = $this->buildGroupedChildMap();
    }

    /** Build a map of [child_id => parent_grouped_id] for all published grouped products. */
    private function buildGroupedChildMap(): array {
        $map        = [];
        $parentIds  = get_posts([
            'post_type'      => 'product',
            'post_status'    => 'publish',
            'posts_per_page' => -1,
            'fields'         => 'ids',
            'tax_query'      => [[ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
                'taxonomy' => 'product_type',
                'field'    => 'slug',
                'terms'    => ['grouped'],
            ]],
        ]);
        foreach ($parentIds as $parentId) {
            $parent = wc_get_product((int) $parentId);
            if (!$parent) continue;
            foreach ($parent->get_children() as $childId) {
                $map[(int) $childId] = (int) $parentId;
            }
        }
        return $map;
    }

    // -------------------------------------------------------------------------
    // Public API
    // -------------------------------------------------------------------------

    /**
     * @param array{country: string, language: string}|null $market
     *   Omit to use the first configured market (or legacy setting).
     * @throws RuntimeException  on missing required field (name or price).
     */
    public function map(int $productId, ?array $market = null): ProductInput {
        $product = wc_get_product($productId);
        if (!$product) {
            throw new RuntimeException( "Product {$productId} not found or not published." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }

        $isVariation    = $product->is_type('variation');
        $isGroupedChild = !$isVariation && isset($this->groupedChildMap[$productId]);
        $parent         = null;

        if ($isVariation) {
            $parent = wc_get_product($product->get_parent_id());
        } elseif ($isGroupedChild) {
            $parent = wc_get_product($this->groupedChildMap[$productId]);
        }

        $this->assertRequired($product, $parent);

        $auth    = new JRA_Merchant_Auth();
        $markets = $auth->getMarkets();
        $market  = $market ?? ($markets[0] ?? ['country' => 'GB', 'language' => 'en']);

        $g     = new ProductInput();
        $attrs = new ProductAttributes();

        // Identity & feed metadata
        $g->setOfferId($this->buildOfferId($product));
        $g->setContentLanguage($market['language']);
        $g->setFeedLabel($market['country']);

        // Core fields
        $attrs->setTitle($this->buildTitle($product, $parent, $isGroupedChild));
        $attrs->setDescription($this->buildDescription($product, $parent));
        $attrs->setLink((string) get_permalink($product->get_id()));
        $attrs->setImageLink($this->buildImageUrl($product, $parent));
        $attrs->setBrand($this->settings['default_brand'] ?? '');
        $attrs->setCondition(Condition::PBNEW);
        $attrs->setAvailability($this->mapAvailability($product));

        if ( jpffgmc_fs()->is__premium_only() ) {
            $availabilityDate = $this->buildAvailabilityDate($product, $parent);
            if ($availabilityDate !== null) {
                $attrs->setAvailabilityDate($this->toTimestamp($availabilityDate));
            }
        }

        // Identifiers
        $sku = $product->get_sku();
        $attrs->setMpn($sku !== '' ? $sku : (string) $productId);

        $gtin = trim((string) $product->get_global_unique_id());
        if ($gtin !== '') {
            $attrs->setGtins([$gtin]);
        }

        $hasBrand = !empty($this->settings['default_brand']);
        $hasMpn   = $sku !== '';
        $attrs->setIdentifierExists($gtin !== '' || ($hasBrand && $hasMpn));

        // item_group_id + variant attributes for variations
        if ($isVariation && $parent) {
            $attrs->setItemGroupId($this->localOfferId($parent->get_id()));
            $this->applyVariantAttributes($attrs, $product, $parent);
        }

        // Pricing
        $price     = (float) ($product->get_price('edit') ?: 0);
        // Composite / bundle products can have price=0; fall back to mapped meta key or custom_price (Pro)
        if ($price <= 0 && jpffgmc_fs()->is__premium_only()) {
            $bundlePriceMetaKey = $this->settings['bundle_price_meta_key'] ?? '';
            if ($bundlePriceMetaKey !== '') {
                $price = (float) get_post_meta($productId, $bundlePriceMetaKey, true);
            }
            if ($price <= 0) {
                $price = (float) get_post_meta($productId, 'custom_price', true);
            }
        }
        if ($price > 0) {
            $attrs->setPrice($this->buildPrice($price));
        }

        $regularPrice = (float) ($product->get_regular_price('edit') ?: 0);
        $salePrice    = (float) ($product->get_sale_price('edit') ?: 0);
        if ($salePrice > 0 && $regularPrice > 0 && $salePrice < $regularPrice) {
            $attrs->setSalePrice($this->buildPrice($salePrice));
        }

        // Shipping weight — convert to grams (Google's preferred unit)
        $weight = (float) $product->get_weight();
        if ($weight > 0) {
            $grams = $this->toGrams($weight, get_option('woocommerce_weight_unit', 'kg'));
            $sw    = new ShippingWeight();
            $sw->setValue($grams);
            $sw->setUnit('g');
            $attrs->setShippingWeight($sw);
        }

        // Additional images (up to 9, skip main)
        $additionalImages = $this->buildAdditionalImages($product, $parent);
        if (!empty($additionalImages)) {
            $attrs->setAdditionalImageLinks($additionalImages);
        }

        // Pro features: productTypes, Google category, sale date, custom labels, offer ID map
        $categorySource = ($isVariation && $parent) ? $parent : $product;
        if ( jpffgmc_fs()->is__premium_only() ) {
            $productTypes = $this->buildProductTypes($categorySource);
            if (!empty($productTypes)) {
                $attrs->setProductTypes($productTypes);
            }

            $this->applyGoogleProductCategory($attrs, $categorySource);

            $salePriceInterval = $this->buildSalePriceEffectiveDate($product);
            if ($salePriceInterval !== null) {
                $attrs->setSalePriceEffectiveDate($salePriceInterval);
            }

            $this->applyCustomLabels($attrs, $product, $parent);
        }

        $g->setProductAttributes($attrs);

        return $g;
    }

    /**
     * Map a batch of product IDs across all configured markets.
     * Each product produces one ProductInput object per market.
     *
     * @param int[] $productIds
     * @return array{mapped: ProductInput[], errors: array<int, string>}
     */
    public function mapBatch(array $productIds): array {
        $mapped      = [];
        $errors      = [];
        $markets     = (new JRA_Merchant_Auth())->getMarkets();
        $marketCount = count($markets);

        foreach ($productIds as $id) {
            $productErrors = [];

            foreach ($markets as $market) {
                try {
                    $mapped[] = $this->map((int) $id, $market);
                } catch (Throwable $e) {
                    $productErrors[$market['country']] = $e->getMessage();
                }
            }

            if (!empty($productErrors)) {
                // All markets failed with the same message — report once
                $messages = array_unique(array_values($productErrors));
                $affected = count($productErrors);
                $suffix   = $affected < $marketCount
                    ? ' (affected ' . $affected . ' of ' . $marketCount . ' markets)'
                    : ' (all ' . $marketCount . ' markets)';
                $errors[$id] = 'Product id=' . $id . ': ' . implode('; ', $messages) . $suffix;
            }
        }

        return compact('mapped', 'errors');
    }

    // -------------------------------------------------------------------------
    // Field builders
    // -------------------------------------------------------------------------

    private function buildOfferId(WC_Product $product): string {
        $localId = $this->localOfferId($product->get_id());
        if ( jpffgmc_fs()->is__premium_only() ) {
            return $this->offerIdMap->lookup($localId) ?? $localId;
        }
        return $localId;
    }

    /**
     * Return the offer ID components for preview/display without building a full ProductInput object.
     * @return array{local: string, effective: string, is_mapped: bool}
     */
    public function resolveOfferId(int $productId): array {
        $localId = $this->localOfferId($productId);
        return $this->offerIdMap->resolve($localId);
    }

    private function localOfferId(int $id): string {
        return 'SKU-' . $id;
    }

    private function buildTitle(WC_Product $product, ?WC_Product $parent, bool $isGroupedChild = false): string {
        if ($product->is_type('variation') && $parent) {
            // Build "{Parent Name} — {Attr Value}, {Attr Value}" from variation attributes
            $attrs  = $product->get_variation_attributes();
            $labels = [];

            foreach ($attrs as $key => $valueSlug) {
                if ($valueSlug === '') {
                    continue;
                }
                $taxonomy = str_replace('attribute_', '', $key);

                if (taxonomy_exists($taxonomy)) {
                    $term     = get_term_by('slug', $valueSlug, $taxonomy);
                    $labels[] = $term ? $term->name : $valueSlug;
                } else {
                    $labels[] = $valueSlug;
                }
            }

            if (!empty($labels)) {
                return $this->sanitiseTitle($parent->get_name() . ' — ' . implode(', ', $labels));
            }

            return $this->sanitiseTitle($product->get_name());
        }

        if ($isGroupedChild && $parent && $this->shouldPrefixGroupTitle($parent->get_id())) {
            return $this->sanitiseTitle($parent->get_name() . ' — ' . $product->get_name());
        }

        return $this->sanitiseTitle($product->get_name());
    }

    private function shouldPrefixGroupTitle(int $parentId): bool {
        $perProduct = get_post_meta($parentId, '_jra_group_title_prefix', true);
        if ($perProduct === '1') return true;
        if ($perProduct === '0') return false;
        // Fall back to global setting — default on
        return (bool) ($this->settings['group_title_prefix'] ?? true);
    }

    private function sanitiseTitle(string $title): string {
        return mb_substr(trim(preg_replace('/\s+/', ' ', $title)), 0, 150);
    }

    private function buildDescription(WC_Product $product, ?WC_Product $parent): string {
        // Priority: variation description → parent description → fallback to name
        $raw = '';

        if ($product->is_type('variation')) {
            $raw = (string) $product->get_description();
        }

        if (trim(wp_strip_all_tags($raw)) === '' && $parent) {
            $raw = (string) $parent->get_description();
        }

        if (trim(wp_strip_all_tags($raw)) === '') {
            $raw = (string) $product->get_description();
        }

        if (trim(wp_strip_all_tags($raw)) === '') {
            $raw = $parent ? $parent->get_name() : $product->get_name();
        }

        $raw = $this->stripShortcodes($raw);
        return mb_substr(trim(wp_strip_all_tags($raw)), 0, 5000);
    }

    /**
     * Strip WordPress / WPBakery shortcodes but leave the inner text content.
     */
    private function stripShortcodes(string $text): string {
        // Remove both self-closing [tag attr="x"/] and paired [tag]...[/tag] wrappers
        return preg_replace('/\[\/?\w[\w\-]*[^\]]*\]/', '', $text);
    }

    private function buildImageUrl(WC_Product $product, ?WC_Product $parent): string {
        $imageId = $product->get_image_id();
        if (!$imageId && $parent) {
            $imageId = $parent->get_image_id();
        }
        if (!$imageId) {
            return '';
        }
        return (string)(wp_get_attachment_image_url($imageId, 'full') ?: '');
    }

    /** @return string[] Up to 9 additional image URLs */
    private function buildAdditionalImages(WC_Product $product, ?WC_Product $parent): array {
        // Prefer the source product's gallery; fall back to parent's
        $galleryIds = $product->get_gallery_image_ids();
        if (empty($galleryIds) && $parent) {
            $galleryIds = $parent->get_gallery_image_ids();
        }

        $links = [];
        foreach (array_slice($galleryIds, 0, 9) as $imgId) {
            $url = wp_get_attachment_image_url((int)$imgId, 'full');
            if ($url) {
                $links[] = (string)$url;
            }
        }

        return $links;
    }

    private function buildPrice(float $amount): Price {
        $price = new Price();
        $price->setAmountMicros((int) round($amount * 1000000));
        $price->setCurrencyCode($this->settings['currency'] ?? 'GBP');
        return $price;
    }

    private function toTimestamp(string $iso8601): Timestamp {
        $dt = new DateTime($iso8601);
        return (new Timestamp())->setSeconds($dt->getTimestamp());
    }

    // -------------------------------------------------------------------------
    // Availability
    // -------------------------------------------------------------------------

    private function mapAvailability(WC_Product $product): int {
        // Composite / bundle availability is determined by components in WooCommerce
        if ($product->is_type('composite') || $product->is_type('bundle')) {
            return Availability::IN_STOCK;
        }

        // Managed stock: check actual quantity
        $qty = $product->get_stock_quantity();
        if ($qty !== null && $qty > 0) {
            return Availability::IN_STOCK;
        }

        // Status-based (covers unmanaged stock set to instock, or onbackorder)
        $status = $product->get_stock_status();
        if ($status === 'instock') {
            return Availability::IN_STOCK;
        }
        if ($status === 'onbackorder') {
            return Availability::PREORDER;
        }

        return Availability::OUT_OF_STOCK;
    }

    /**
     * Returns an ISO8601 availability date string, or null if in stock / no date set.
     */
    private function buildAvailabilityDate(WC_Product $product, ?WC_Product $parent): ?string {
        if ($this->mapAvailability($product) === Availability::IN_STOCK) {
            return null;
        }

        $id       = $product->get_id();
        $parentId = $parent?->get_id();

        $dueDateKey    = $this->settings['due_date_meta_key']    ?? '';
        $dueDateFormat = $this->settings['due_date_meta_format'] ?? '';
        $leadTimeKey   = $this->settings['lead_time_meta_key']   ?? '';

        // due_date — check variation then parent
        $dueDateMeta = $dueDateKey !== '' ? $dueDateKey : '_due_date';
        $rawDueDate  = trim((string) get_post_meta($id, $dueDateMeta, true));
        if ($rawDueDate === '' && $parentId) {
            $rawDueDate = trim((string) get_post_meta($parentId, $dueDateMeta, true));
        }
        if ($rawDueDate !== '') {
            if ($dueDateKey !== '' && $dueDateFormat !== '') {
                // Mapped key with a specified PHP date format
                $parsed = DateTime::createFromFormat($dueDateFormat, $rawDueDate, new DateTimeZone('UTC'));
                if ($parsed) {
                    return $parsed->format('Y-m-d') . 'T00:00:00+00:00';
                }
            } elseif ($dueDateKey !== '') {
                // Mapped key with no format — default to Y/m/d
                $parsed = DateTime::createFromFormat('Y/m/d', $rawDueDate, new DateTimeZone('UTC'));
                if ($parsed) {
                    return $parsed->format('Y-m-d') . 'T00:00:00+00:00';
                }
            } else {
                // Plugin's own _due_date — stored as Y-m-d
                return $rawDueDate . 'T00:00:00+00:00';
            }
        }

        // lead_time (integer days) — check variation then parent
        $leadTimeMeta = $leadTimeKey !== '' ? $leadTimeKey : '_lead_time';
        $leadTime     = (int) get_post_meta($id, $leadTimeMeta, true);
        if ($leadTime <= 0 && $parentId) {
            $leadTime = (int) get_post_meta($parentId, $leadTimeMeta, true);
        }
        if ($leadTime > 0) {
            $date = new DateTime('now', new DateTimeZone('UTC'));
            $date->modify("+{$leadTime} days");
            return $date->format('Y-m-d') . 'T00:00:00+00:00';
        }

        // Default lead time fallback — applies when backorders are allowed and no per-product date is set
        $defaultLeadTime = (int) ($this->settings['default_lead_time'] ?? 0);
        if ($defaultLeadTime > 0 && $product->get_backorders() !== 'no') {
            $date = new DateTime('now', new DateTimeZone('UTC'));
            $date->modify("+{$defaultLeadTime} days");
            return $date->format('Y-m-d') . 'T00:00:00+00:00';
        }

        return null;
    }

    // -------------------------------------------------------------------------
    // Variant attribute mapping
    // -------------------------------------------------------------------------

    /**
     * For a variation, look up each Google variant attribute in the attribute
     * mappings table and set the corresponding field on the ProductAttributes object.
     *
     * Attribute mapping: Google attr → WC attribute taxonomy slug (e.g. 'pa_colour')
     * Term value mapping: Google attr → ['local term name' => 'google value']
     */
    private function applyVariantAttributes(ProductAttributes $attrs, WC_Product $variation, WC_Product $parent): void {
        if (empty($this->attrMappings)) {
            return;
        }

        // WC stores variation attribute keys as 'attribute_{taxonomy}'
        $variationAttrs = $variation->get_variation_attributes();

        foreach ($this->attrMappings as $googleAttr => $wcAttrSlug) {
            if (empty($wcAttrSlug) || !in_array($googleAttr, self::GOOGLE_ATTRS, true)) {
                continue;
            }

            $key       = 'attribute_' . $wcAttrSlug;
            $valueSlug = $variationAttrs[$key] ?? '';

            if ($valueSlug !== '') {
                // Variation-level attribute — resolve slug to human-readable term name
                $termName = $valueSlug;
                if (taxonomy_exists($wcAttrSlug)) {
                    $term = get_term_by('slug', $valueSlug, $wcAttrSlug);
                    if ($term) {
                        $termName = $term->name;
                    }
                }
            } else {
                // Attribute not set on this variation — inherit from parent (e.g. age_group
                // shared across all variations). get_attribute() returns term names as a
                // comma-separated string; take the first value.
                $parentValue = $parent->get_attribute($wcAttrSlug);
                if ($parentValue === '') {
                    continue;
                }
                $termName = trim(explode(',', $parentValue)[0]);
            }

            // Apply optional term value mapping (e.g. "Adult" → "adult")
            $mapped = $this->termMappings[$googleAttr][$termName] ?? $termName;

            switch ($googleAttr) {
                case 'color':     $attrs->setColor($mapped);                                    break;
                case 'size':      $attrs->setSize($mapped);                                     break;
                case 'material':  $attrs->setMaterial($mapped);                                 break;
                case 'pattern':   $attrs->setPattern($mapped);                                  break;
                case 'age_group': $attrs->setAgeGroup(AgeGroup::value(strtoupper($mapped)));     break;
                case 'gender':    $attrs->setGender(Gender::value(strtoupper($mapped)));         break;
            }
        }
    }

    // -------------------------------------------------------------------------
    // Product types, Google category, sale date, custom labels
    // -------------------------------------------------------------------------

    /** @return string[] Up to 5 full category paths, e.g. "Bikes > Mountain Bikes" */
    private function buildProductTypes(WC_Product $product): array {
        $terms = wc_get_product_terms($product->get_id(), 'product_cat', ['fields' => 'all']);
        $paths = [];
        foreach ((array) $terms as $term) {
            $path = $this->buildCategoryPath($term);
            if ($path !== '') {
                $paths[] = $path;
            }
        }
        return array_slice(array_unique($paths), 0, 5);
    }

    private function buildCategoryPath(\WP_Term $term): string {
        $ancestors = get_ancestors($term->term_id, 'product_cat', 'taxonomy');
        $parts     = [];
        foreach (array_reverse($ancestors) as $ancestorId) {
            $ancestor = get_term($ancestorId, 'product_cat');
            if ($ancestor && !is_wp_error($ancestor)) {
                $parts[] = $ancestor->name;
            }
        }
        $parts[] = $term->name;
        return implode(' > ', $parts);
    }

    private function applyGoogleProductCategory(ProductAttributes $attrs, WC_Product $product): void {
        $termIds = wc_get_product_terms($product->get_id(), 'product_cat', ['fields' => 'ids']);
        foreach ((array) $termIds as $termId) {
            if (!empty($this->googleCategoryMap[$termId])) {
                $attrs->setGoogleProductCategory($this->googleCategoryMap[$termId]);
                return;
            }
        }
        $default = trim($this->settings['default_google_category'] ?? '');
        if ($default !== '') {
            $attrs->setGoogleProductCategory($default);
        }
    }

    private function buildSalePriceEffectiveDate(WC_Product $product): ?Interval {
        $salePrice = (float) $product->get_sale_price('edit');
        if ($salePrice <= 0) {
            return null;
        }
        $from = $product->get_date_on_sale_from('edit');
        $to   = $product->get_date_on_sale_to('edit');
        if (!$from || !$to) {
            return null;
        }
        return (new Interval())
            ->setStartTime((new Timestamp())->setSeconds($from->getTimestamp()))
            ->setEndTime((new Timestamp())->setSeconds($to->getTimestamp()));
    }

    private function applyCustomLabels(ProductAttributes $attrs, WC_Product $product, ?WC_Product $parent): void {
        // Label 0: automatic — sale vs full-price
        $salePrice = (float) $product->get_sale_price('edit');
        $regPrice  = (float) $product->get_regular_price('edit');
        $attrs->setCustomLabel0($salePrice > 0 && $salePrice < $regPrice ? 'sale' : 'full-price');

        // Label 1: tag-mapped — check parent for variations (tags live on the parent)
        $tagLabel = $this->resolveTagLabel($parent ?? $product);
        if ($tagLabel !== '') {
            $attrs->setCustomLabel1($tagLabel);
        }

        // Label 2: automatic — stock level
        $attrs->setCustomLabel2($this->resolveStockLabel($product));

        // Label 3: category-mapped — check parent for variations
        $catLabel = $this->resolveCategoryLabel($parent ?? $product);
        if ($catLabel !== '') {
            $attrs->setCustomLabel3($catLabel);
        }

        // Label 4: manual meta (_jra_custom_label), variation falls back to parent
        $manual = trim((string) get_post_meta($product->get_id(), '_jra_custom_label', true));
        if ($manual === '' && $parent) {
            $manual = trim((string) get_post_meta($parent->get_id(), '_jra_custom_label', true));
        }
        if ($manual !== '') {
            $attrs->setCustomLabel4($manual);
        }
    }

    private function resolveTagLabel(WC_Product $product): string {
        if (empty($this->customLabelTagMap)) {
            return '';
        }
        $tags = wc_get_product_terms($product->get_id(), 'product_tag', ['fields' => 'slugs']);
        foreach ((array) $tags as $slug) {
            if (!empty($this->customLabelTagMap[$slug])) {
                return $this->customLabelTagMap[$slug];
            }
        }
        return '';
    }

    private function resolveStockLabel(WC_Product $product): string {
        $availability = $this->mapAvailability($product);
        if ($availability === Availability::PREORDER) {
            return 'preorder';
        }
        if ($availability !== Availability::IN_STOCK) {
            return 'out-of-stock';
        }
        $qty       = $product->get_stock_quantity();
        $threshold = max(1, (int) ($this->settings['low_stock_threshold'] ?? 5));
        if ($qty !== null && $qty <= $threshold) {
            return 'low-stock';
        }
        return 'in-stock';
    }

    private function resolveCategoryLabel(WC_Product $product): string {
        if (empty($this->customLabelCatMap)) {
            return '';
        }
        $termIds = wc_get_product_terms($product->get_id(), 'product_cat', ['fields' => 'ids']);
        foreach ((array) $termIds as $termId) {
            if (!empty($this->customLabelCatMap[$termId])) {
                return $this->customLabelCatMap[$termId];
            }
        }
        return '';
    }

    // -------------------------------------------------------------------------
    // Validation
    // -------------------------------------------------------------------------

    private function assertRequired(WC_Product $product, ?WC_Product $parent): void {
        $name = $product->get_name() ?: ($parent?->get_name() ?? '');
        if ($name === '') {
            throw new RuntimeException("has no name.");
        }

        $price = (float)($product->get_price('edit') ?: 0);
        if ($price <= 0 && jpffgmc_fs()->is__premium_only()) {
            $bundlePriceMetaKey = $this->settings['bundle_price_meta_key'] ?? '';
            if ($bundlePriceMetaKey !== '') {
                $price = (float) get_post_meta($product->get_id(), $bundlePriceMetaKey, true);
            }
            if ($price <= 0) {
                $price = (float) get_post_meta($product->get_id(), 'custom_price', true);
            }
        }
        if ($price <= 0) {
            throw new RuntimeException("has no price.");
        }
    }

    // -------------------------------------------------------------------------
    // Setup helpers
    // -------------------------------------------------------------------------

    /** @return array<string, array<string, string>> */
    private function loadTermMappings(): array {
        global $wpdb;
        $table = $wpdb->prefix . 'jra_merchant_term_mappings';

        // Gracefully return empty if table doesn't exist yet (pre-activation)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) {
            return [];
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $rows   = $wpdb->get_results("SELECT google_attribute, local_value, google_value FROM {$table}");
        $result = [];

        foreach ((array)$rows as $row) {
            $result[$row->google_attribute][$row->local_value] = $row->google_value;
        }

        return $result;
    }

    private function toGrams(float $weight, string $unit): float {
        return match ($unit) {
            'kg'    => $weight * 1000,
            'lbs'   => $weight * 453.592,
            'oz'    => $weight * 28.3495,
            default => $weight, // assume already grams
        };
    }
}
