<?php

namespace SoftminalB2B\Import;

if (!defined('ABSPATH')) {
    exit;
}

use SoftminalB2B\Models\PriceRule;

/**
 * Imports from B2BKing (H8).
 *
 * B2BKing keeps groups and rules as custom post types and the user's group on
 * user meta. Its rule model has two dimensions — who (group/user/everyone) and
 * what it applies to (all/product/category) — which map onto our (group_id ×
 * scope). We read everything from core tables directly, so the import works
 * with B2BKing deactivated and never touches its data.
 *
 * Faithful where the models line up; honest where they don't: rule types beyond
 * the three we support, and per-product rules targeting a single user (our
 * customer scope is store-wide), are reported as warnings rather than
 * silently approximated.
 */
class B2BKingImporter extends SourceImporter
{
    public function slug(): string
    {
        return 'b2bking';
    }

    public function label(): string
    {
        return __('B2BKing', 'softminal-b2b-wholesale-for-woocommerce');
    }

    public function isPresent(): bool
    {
        return $this->countPosts('b2bking_group') > 0 || $this->countPosts('b2bking_rule') > 0;
    }

    private function countPosts(string $type): int
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status <> 'trash'",
            $type
        ));
    }

    /**
     * @return array<string, mixed>
     */
    public function collect(): array
    {
        global $wpdb;

        $groups           = [];
        $users            = [];
        $rules            = [];
        $quantity_rules   = [];
        $tax_exempt_groups = [];
        $visibility       = [];
        $method_gating    = [];
        $companies        = [];
        $quotes           = [];
        $warnings         = [];

        // --- Groups (b2bking_group CPT) -------------------------------------
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $groupPosts = $wpdb->get_results(
            "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type = 'b2bking_group' AND post_status <> 'trash'"
        );

        foreach ($groupPosts as $g) {
            $groups[] = ['source_id' => (string) $g->ID, 'name' => (string) $g->post_title ?: ('Group #' . $g->ID)];
        }

        // --- Users (b2bking_customergroup meta) -----------------------------
        // A meta_key lookup is the only way to find B2BKing's group members, and
        // this runs once, on an admin-initiated migration — not a request path —
        // so the slow-query concern doesn't apply here.
        $memberIds = get_users([
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration read; see note above.
            'meta_key' => 'b2bking_customergroup',
            'fields'   => 'ID',
        ]);

        foreach ($memberIds as $userId) {
            $sourceGroup = (string) get_user_meta((int) $userId, 'b2bking_customergroup', true);

            if ($sourceGroup === '' || $sourceGroup === 'b2c') {
                continue;
            }

            $approved = get_user_meta((int) $userId, 'b2bking_account_approved', true);

            $users[] = [
                'user_id'         => (int) $userId,
                'source_group_id' => $sourceGroup,
                'status'          => $approved === 'no' ? 'pending' : 'approved',
            ];
        }

        // --- Rules (b2bking_rule CPT) ---------------------------------------
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rulePosts = $wpdb->get_col(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'b2bking_rule' AND post_status <> 'trash'"
        );

        foreach ($rulePosts as $ruleId) {
            $this->collectRule((int) $ruleId, $rules, $quantity_rules, $tax_exempt_groups, $visibility, $warnings);
        }

        // --- Per-group product prices + tiers (product meta, not rule CPT) ----
        // A whole second pricing source B2BKing keeps directly on the product:
        // fixed per-group price + quantity price tiers.
        $groupSourceIds = array_map(static fn ($g) => (string) $g['source_id'], $groups);
        $this->collectProductPrices($groupSourceIds, $rules, $warnings);

        // --- Product-level min/max/step (applies to all customers) -----------
        $this->collectProductQuantities($quantity_rules);

        // --- Catalog visibility ---------------------------------------------
        // Whole-store B2B mode → full private catalogue.
        if (get_option('b2bking_plugin_status_setting') === 'b2b') {
            $visibility['full_private'] = true;
        }
        $this->collectCategoryVisibility($groupSourceIds, $visibility);

        // --- Per-group payment/shipping method gating -----------------------
        $this->collectMethodGating($groupSourceIds, $method_gating, $warnings);

        // --- Companies / sub-accounts (+ per-company credit) ----------------
        $this->collectCompanies($companies, $warnings);

        // --- Offers → quotes (line items; conversations not migrated) --------
        $this->collectQuotes($quotes, $warnings);

        return compact('groups', 'users', 'rules', 'quantity_rules', 'tax_exempt_groups', 'visibility', 'method_gating', 'companies', 'quotes', 'warnings');
    }

    /**
     * B2BKing "offers" (merchant-created quotes) → our quotes, one per targeted
     * customer. Line items live in `b2bking_offer_details` as
     * "productId;price;qty|productId;price;qty". Group-only offers (no specific
     * users) and the RFQ conversation threads are not migrated.
     *
     * @param array<int, array<string, mixed>> $quotes
     * @param array<int, string>               $warnings
     */
    private function collectQuotes(array &$quotes, array &$warnings): void
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $offerIds = $wpdb->get_col(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'b2bking_offer' AND post_status <> 'trash'"
        );

        $groupOnly = 0;

        foreach ($offerIds as $offerId) {
            $items = $this->parseOfferItems((string) get_post_meta((int) $offerId, 'b2bking_offer_details', true));
            if (empty($items)) {
                continue;
            }

            $userList = (string) get_post_meta((int) $offerId, 'b2bking_category_users_textarea', true);
            $userIds  = array_values(array_unique(array_filter(array_map('intval', explode(',', $userList)))));

            if (empty($userIds)) {
                $groupOnly++;
                continue;
            }

            foreach ($userIds as $userId) {
                $quotes[] = [
                    'source_key' => 'bkoffer:' . $offerId . ':' . $userId,
                    'user_id'    => $userId,
                    'status'     => 'offered',
                    'items'      => $items,
                ];
            }
        }

        if ($groupOnly > 0) {
            $warnings[] = sprintf(
                /* translators: %d: number of offers */
                _n(
                    '%d B2BKing offer targets a whole group rather than named customers. A quote here belongs to one customer, so it was skipped — re-issue it to the customers who need it.',
                    '%d B2BKing offers target whole groups rather than named customers. A quote here belongs to one customer, so they were skipped — re-issue them to the customers who need them.',
                    $groupOnly,
                    'softminal-b2b-wholesale-for-woocommerce'
                ),
                $groupOnly
            );
        }
    }

    /**
     * Parse B2BKing's "productId;price;qty|..." offer line items. Items keyed by
     * product name (non-numeric) can't be resolved reliably, so they're dropped.
     *
     * @return array<int, array<string, mixed>>
     */
    private function parseOfferItems(string $details): array
    {
        $items = [];

        foreach (array_filter(explode('|', $details)) as $line) {
            $parts   = explode(';', $line);
            $product = trim((string) ($parts[0] ?? ''));

            if ($product === '' || !ctype_digit($product)) {
                continue;
            }

            $items[] = [
                'product_id'    => (int) $product,
                'quantity'      => max(1, (int) ($parts[2] ?? 1)),
                'offered_price' => isset($parts[1]) && $parts[1] !== '' ? (float) $parts[1] : null,
            ];
        }

        return $items;
    }

    /**
     * B2BKing gates methods per group via post meta on the group:
     * `b2bking_group_payment_method_{gatewayId}` = '1'|'0' (0 = hidden). We map
     * payment gating to our allow-list only when the group is actually restricted
     * (has at least one hidden gateway). Shipping keys concatenate method+instance
     * ids ambiguously, so a restricted shipping set is reported for manual review.
     *
     * @param array<int, string>               $groupSourceIds b2bking group post ids
     * @param array<int, array<string, mixed>> $gating
     * @param array<int, string>               $warnings
     */
    private function collectMethodGating(array $groupSourceIds, array &$gating, array &$warnings): void
    {
        $shippingRestricted = false;

        foreach ($groupSourceIds as $groupId) {
            $meta = get_post_meta((int) $groupId);
            if (!is_array($meta)) {
                continue;
            }

            $allowedPayments = [];
            $paymentDenied   = false;

            foreach ($meta as $key => $values) {
                $value = is_array($values) ? (string) reset($values) : (string) $values;

                if (strpos($key, 'b2bking_group_payment_method_') === 0) {
                    $gateway = substr($key, strlen('b2bking_group_payment_method_'));
                    if ($value === '0') {
                        $paymentDenied = true;
                    } elseif ($gateway !== '') {
                        $allowedPayments[] = $gateway;
                    }
                } elseif (strpos($key, 'b2bking_group_shipping_method_') === 0 && $value === '0') {
                    $shippingRestricted = true;
                }
            }

            // Only record an allow-list when the group is genuinely restricted.
            if ($paymentDenied) {
                $gating[] = [
                    'source_group_id' => (string) $groupId,
                    'payments'        => array_values(array_unique($allowedPayments)),
                ];
            }
        }

        if ($shippingRestricted) {
            $warnings[] = __('B2BKing restricts shipping methods for one or more groups. B2B Suite gates shipping too, but the method ids differ, so set the shipping allow-lists by hand under Settings → Payment & shipping.', 'softminal-b2b-wholesale-for-woocommerce');
        }
    }

    /**
     * B2BKing models a company as a parent user with sub-accounts (user meta
     * `b2bking_account_parent` on each sub-account). Group them by parent → one
     * company per parent, with the parent as admin and sub-accounts as buyers.
     *
     * @param array<int, array<string, mixed>> $companies
     * @param array<int, string>               $warnings
     */
    private function collectCompanies(array &$companies, array &$warnings): void
    {
        // All sub-accounts point at their parent (company owner) here.
        $subIds = get_users([
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration read.
            'meta_key' => 'b2bking_account_parent',
            'fields'   => 'ID',
        ]);

        $byParent = [];
        foreach ($subIds as $subId) {
            $parentId = (int) get_user_meta((int) $subId, 'b2bking_account_parent', true);
            if ($parentId > 0) {
                $byParent[$parentId][] = (int) $subId;
            }
        }

        foreach ($byParent as $parentId => $members) {
            $parent = get_userdata($parentId);
            if (!$parent) {
                continue;
            }

            $name = get_user_meta($parentId, 'billing_company', true);
            $name = $name !== '' ? $name : ($parent->display_name ?: $parent->user_login);

            $group = (string) get_user_meta($parentId, 'b2bking_customergroup', true);

            $companyMembers = [['user_id' => (int) $parentId, 'role' => 'admin']];
            foreach ($members as $memberId) {
                $companyMembers[] = ['user_id' => $memberId, 'role' => 'buyer'];
            }

            $companies[] = [
                'source_key'      => 'bkco:' . $parentId,
                'name'            => (string) $name,
                'source_group_id' => ($group !== '' && $group !== 'b2c') ? $group : null,
                'vat_number'      => (string) get_user_meta($parentId, 'b2bking_user_vat', true) ?: null,
                'members'         => $companyMembers,
                // Net terms / credit: our credit is per company, so it rides the
                // company owner's B2BKing credit limit + consumed balance.
                'credit'          => $this->companyCredit($parentId),
            ];
        }

        $this->warnStandaloneCredit(array_keys($byParent), $warnings);
    }

    /**
     * The company owner's credit limit + consumed balance, or null if none.
     *
     * @return array<string, mixed>|null
     */
    private function companyCredit(int $userId): ?array
    {
        $limit = (float) get_user_meta($userId, 'b2bking_user_credit_limit', true);

        if ($limit <= 0) {
            return null;
        }

        return [
            'limit'      => $limit,
            'consumed'   => (float) get_user_meta($userId, 'b2bking_user_credit_consumed_balance', true),
            'terms_days' => 30,
        ];
    }

    /**
     * B2BKing credit can sit on any user; ours is per company. Report the count
     * of credit-holding users that aren't company owners rather than dropping
     * them silently.
     *
     * @param array<int, int>    $companyOwnerIds
     * @param array<int, string> $warnings
     */
    private function warnStandaloneCredit(array $companyOwnerIds, array &$warnings): void
    {
        $owners = array_flip(array_map('intval', $companyOwnerIds));

        $creditUsers = get_users([
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration read.
            'meta_key' => 'b2bking_user_credit_limit',
            'fields'   => 'ID',
        ]);

        $count = 0;
        foreach ($creditUsers as $uid) {
            if (isset($owners[(int) $uid])) {
                continue;
            }
            if ((float) get_user_meta((int) $uid, 'b2bking_user_credit_limit', true) > 0) {
                $count++;
            }
        }

        if ($count > 0) {
            $warnings[] = sprintf(
                /* translators: %d: number of customers */
                _n(
                    '%d customer has a credit limit but is not a company account. B2B Suite runs credit per company, so create a company for them (or ignore) to carry the limit over.',
                    '%d customers have credit limits but are not company accounts. B2B Suite runs credit per company, so create companies for them (or ignore) to carry the limits over.',
                    $count,
                    'softminal-b2b-wholesale-for-woocommerce'
                ),
                $count
            );
        }
    }

    /**
     * @param array<int, array<string, mixed>> $rules
     * @param array<int, string>               $warnings
     */
    private function collectRule(int $ruleId, array &$rules, array &$quantityRules, array &$taxExemptGroups, array &$visibility, array &$warnings): void
    {
        $what = (string) get_post_meta($ruleId, 'b2bking_rule_what', true);

        // Order quantity constraints map to our quantity_rules table.
        if (in_array($what, ['minimum_order', 'maximum_order', 'required_multiple'], true)) {
            $this->collectQuantityRule($ruleId, $what, $quantityRules, $warnings);
            return;
        }

        // Tax exemption maps to our per-group tax-exempt setting.
        if (in_array($what, ['tax_exemption', 'tax_exemption_user'], true)) {
            $this->collectTaxExemption($ruleId, $taxExemptGroups, $warnings);
            return;
        }

        // Hidden prices map to our hide-prices-for-guests visibility setting.
        if ($what === 'hidden_price') {
            $this->collectHiddenPrice($ruleId, $visibility, $warnings);
            return;
        }

        // Free shipping has no direct rule, but there's a clean manual path.
        if ($what === 'free_shipping') {
            $warnings[] = sprintf(
                /* translators: %d: rule id */
                __('B2BKing free-shipping rule #%d wasn’t imported. Recreate it with WooCommerce’s built-in Free Shipping method and restrict it to this group under Settings → Payment & shipping.', 'softminal-b2b-wholesale-for-woocommerce'),
                $ruleId
            );
            return;
        }

        $typeMap = [
            'discount_percentage' => PriceRule::TYPE_PERCENT,
            'discount_amount'     => PriceRule::TYPE_AMOUNT_OFF,
            'fixed_price'         => PriceRule::TYPE_FIXED,
        ];

        if (!isset($typeMap[$what])) {
            $warnings[] = sprintf(
                /* translators: 1: rule id, 2: rule type */
                __('B2BKing rule #%1$d is a "%2$s" rule, which has no equivalent yet — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                $ruleId,
                $what ?: 'unknown'
            );
            return;
        }

        $value   = (float) get_post_meta($ruleId, 'b2bking_rule_howmuch', true);
        $minQty  = max(1, (int) get_post_meta($ruleId, 'b2bking_rule_quantity_value', true) ?: 1);
        $whoList = $this->tokens($ruleId, 'b2bking_rule_who', 'b2bking_rule_who_multiple_options');
        $applies = $this->tokens($ruleId, 'b2bking_rule_applies', 'b2bking_rule_applies_multiple_options');

        foreach ($whoList as $who) {
            [$whoKind, $whoId] = $this->parseToken($who);

            foreach ($applies as $target) {
                [$scope, $objectId] = $this->parseApplies($target);

                if ($scope === null) {
                    continue;
                }

                // Group targets map cleanly to a group rule.
                if ($whoKind === 'group') {
                    $rules[] = [
                        'source_key'      => 'rule:' . $ruleId . ':' . $target,
                        'source_group_id' => $whoId,
                        'customer_id'     => null,
                        'scope'           => $scope,
                        'object_id'       => $objectId,
                        'type'            => $typeMap[$what],
                        'value'           => $value,
                        'min_qty'         => $minQty,
                    ];
                    continue;
                }

                // "Everyone registered" fans out to every imported group.
                if (in_array($who, ['everyone_registered', 'everyone_registered_b2b'], true)) {
                    $rules[] = [
                        'source_key'      => 'rule:' . $ruleId . ':' . $target,
                        'source_group_id' => '*',
                        'customer_id'     => null,
                        'scope'           => $scope,
                        'object_id'       => $objectId,
                        'type'            => $typeMap[$what],
                        'value'           => $value,
                        'min_qty'         => $minQty,
                    ];
                    continue;
                }

                // A rule for one user maps to our customer scope — but that scope
                // is store-wide, so it can't also target a single product.
                if ($whoKind === 'user') {
                    if ($scope === PriceRule::SCOPE_GLOBAL) {
                        $rules[] = [
                            'source_key'      => 'rule:' . $ruleId . ':user:' . $whoId,
                            'source_group_id' => null,
                            'customer_id'     => (int) $whoId,
                            'scope'           => PriceRule::SCOPE_CUSTOMER,
                            'object_id'       => (int) $whoId,
                            'type'            => $typeMap[$what],
                            'value'           => $value,
                            'min_qty'         => $minQty,
                        ];
                    } else {
                        $warnings[] = sprintf(
                            /* translators: %d: rule id */
                            __('B2BKing rule #%d targets a single user and a specific product/category; a per-customer rule here applies store-wide, so it was skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                            $ruleId
                        );
                    }
                    continue;
                }

                $warnings[] = sprintf(
                    /* translators: 1: rule id, 2: who token */
                    __('B2BKing rule #%1$d targets "%2$s", which is not mapped — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $ruleId,
                    $who
                );
            }
        }
    }

    /**
     * A B2BKing minimum_order / maximum_order / required_multiple rule → one of
     * our quantity rules per who × applies target.
     *
     * @param array<int, array<string, mixed>> $quantityRules
     * @param array<int, string>               $warnings
     */
    private function collectQuantityRule(int $ruleId, string $what, array &$quantityRules, array &$warnings): void
    {
        $howmuch = (int) get_post_meta($ruleId, 'b2bking_rule_howmuch', true);
        // 'quantity' = measured in pieces, 'value' = measured in currency.
        $measure = (string) get_post_meta($ruleId, 'b2bking_rule_quantity_value', true);

        if ($howmuch <= 0) {
            return;
        }

        $whoList = $this->tokens($ruleId, 'b2bking_rule_who', 'b2bking_rule_who_multiple_options');
        $applies = $this->tokens($ruleId, 'b2bking_rule_applies', 'b2bking_rule_applies_multiple_options');

        $fields = $this->quantityFields($what, $howmuch, $measure, $ruleId, $warnings);
        if ($fields === null) {
            return;
        }

        foreach ($whoList as $who) {
            [$whoKind, $whoId] = $this->parseToken($who);

            if ($whoKind === 'group') {
                $sourceGroup = $whoId;
            } elseif (in_array($who, ['everyone_registered', 'everyone_registered_b2b'], true)) {
                $sourceGroup = '*';
            } else {
                $warnings[] = sprintf(
                    /* translators: 1: rule id, 2: who token */
                    __('B2BKing quantity rule #%1$d targets "%2$s", which a quantity rule here can’t scope to — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $ruleId,
                    $who
                );
                continue;
            }

            foreach ($applies as $target) {
                [$scope, $objectId] = $this->parseApplies($target);

                if ($scope === null) {
                    continue;
                }

                $quantityRules[] = array_merge([
                    'source_key'      => 'qrule:' . $ruleId . ':' . $target,
                    'source_group_id' => $sourceGroup,
                    'scope'           => $scope,
                    'object_id'       => $objectId,
                    'min_qty'         => null,
                    'max_qty'         => null,
                    'step_qty'        => null,
                    'min_order_value' => null,
                ], $fields);
            }
        }
    }

    /**
     * Map a quantity rule type + amount to our quantity-rule columns, or null
     * (with a warning) when it has no representation.
     *
     * @param array<int, string> $warnings
     * @return array<string, mixed>|null
     */
    private function quantityFields(string $what, int $howmuch, string $measure, int $ruleId, array &$warnings): ?array
    {
        if ($what === 'minimum_order') {
            return $measure === 'value'
                ? ['min_order_value' => (float) $howmuch]
                : ['min_qty' => $howmuch];
        }

        if ($what === 'maximum_order') {
            if ($measure === 'value') {
                $warnings[] = sprintf(
                    /* translators: %d: rule id */
                    __('B2BKing rule #%d caps the order *value*, which has no equivalent (we cap quantity, not spend) — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $ruleId
                );
                return null;
            }
            return ['max_qty' => $howmuch];
        }

        // required_multiple → pack/step size.
        return ['step_qty' => $howmuch];
    }

    /**
     * A B2BKing tax_exemption / tax_exemption_user rule → our per-group
     * tax-exempt flag. Only whole-account (cart-wide) group exemptions map; a
     * product/category-scoped or per-user exemption is reported, not guessed.
     *
     * @param array<int, string> $taxExemptGroups source group ids to exempt
     * @param array<int, string> $warnings
     */
    private function collectTaxExemption(int $ruleId, array &$taxExemptGroups, array &$warnings): void
    {
        $whoList = $this->tokens($ruleId, 'b2bking_rule_who', 'b2bking_rule_who_multiple_options');
        $applies = $this->tokens($ruleId, 'b2bking_rule_applies', 'b2bking_rule_applies_multiple_options');

        foreach ($applies as $target) {
            [$scope] = $this->parseApplies($target);
            if ($scope !== null && $scope !== PriceRule::SCOPE_GLOBAL) {
                $warnings[] = sprintf(
                    /* translators: %d: rule id */
                    __('B2BKing tax-exemption rule #%d is limited to specific products/categories; B2B Suite exempts a whole customer group, so it was skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $ruleId
                );
                return;
            }
        }

        foreach ($whoList as $who) {
            [$kind, $id] = $this->parseToken($who);

            if ($kind === 'group') {
                $taxExemptGroups[] = $id;
            } else {
                $warnings[] = sprintf(
                    /* translators: 1: rule id, 2: who token */
                    __('B2BKing tax-exemption rule #%1$d applies to "%2$s"; B2B Suite exempts whole groups, so it was skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $ruleId,
                    $who
                );
            }
        }
    }

    /**
     * A B2BKing hidden_price rule → our hide-prices-for-guests setting when it
     * targets logged-out visitors. Per-group price hiding has no equivalent
     * (we hide prices from non-B2B viewers, not from a specific logged-in group).
     *
     * @param array<string, mixed> $visibility
     * @param array<int, string>   $warnings
     */
    private function collectHiddenPrice(int $ruleId, array &$visibility, array &$warnings): void
    {
        foreach ($this->tokens($ruleId, 'b2bking_rule_who', 'b2bking_rule_who_multiple_options') as $who) {
            if ($who === 'user_0') {
                $visibility['hide_prices_guests'] = true;
            } else {
                $warnings[] = sprintf(
                    /* translators: 1: rule id, 2: who token */
                    __('B2BKing hides prices for "%2$s" (rule #%1$d); B2B Suite hides prices from logged-out visitors — set per-group price hiding by hand if needed.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $ruleId,
                    $who
                );
            }
        }
    }

    /**
     * B2BKing stores per-group category visibility as term meta on product_cat:
     * `b2bking_group_{gid} = '0'` means the category is hidden from that group.
     * Collect those into our group→hidden-categories map.
     *
     * @param array<int, string>   $groupSourceIds imported group ids
     * @param array<string, mixed> $visibility
     */
    private function collectCategoryVisibility(array $groupSourceIds, array &$visibility): void
    {
        if (empty($groupSourceIds)) {
            return;
        }

        global $wpdb;

        $wanted = array_flip(array_map('strval', $groupSourceIds));

        // One join to read only product_cat term meta of the hidden-per-group shape.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            "SELECT tm.term_id, tm.meta_key
             FROM {$wpdb->termmeta} tm
             INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = tm.term_id AND tt.taxonomy = 'product_cat'
             WHERE tm.meta_key LIKE 'b2bking_group_%' AND tm.meta_value = '0'"
        );

        foreach ($rows as $row) {
            if (!preg_match('/^b2bking_group_(.+)$/', (string) $row->meta_key, $m)) {
                continue;
            }
            $gid = $m[1];

            if ($gid === 'b2c' || $gid === '0' || !isset($wanted[$gid])) {
                continue;
            }

            $visibility['group_hidden_categories'][$gid][] = (int) $row->term_id;
        }
    }

    /**
     * B2BKing's per-group product pricing lives on the product itself, not in the
     * rule CPT: a fixed per-group price (`b2bking_regular_product_price_group_{gid}`
     * / `b2bking_sale_product_price_group_{gid}`) and quantity price tiers
     * (`b2bking_product_pricetiers_group_{gid}` = "qty:price;qty:price;"). Each
     * becomes a product-scoped price rule (tiers → one rule per quantity break).
     *
     * @param array<int, string>               $groupSourceIds imported group ids
     * @param array<int, array<string, mixed>> $rules
     * @param array<int, string>               $warnings
     */
    private function collectProductPrices(array $groupSourceIds, array &$rules, array &$warnings): void
    {
        if (empty($groupSourceIds)) {
            return;
        }

        global $wpdb;

        $wanted = array_flip(array_map('strval', $groupSourceIds));

        // Tiers are stored as a discount percentage when this option is on, else
        // as an absolute price.
        $tiersArePercent = (int) get_option('b2bking_enter_percentage_tiered_setting', 0) === 1;

        // One scan of the meta table for all three per-group price key shapes.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta}
             WHERE meta_key LIKE 'b2bking_regular_product_price_group_%'
                OR meta_key LIKE 'b2bking_sale_product_price_group_%'
                OR meta_key LIKE 'b2bking_product_pricetiers_group_%'"
        );

        // Accumulate per product+group so a fixed price and its tiers combine.
        $byProductGroup = [];
        foreach ($rows as $row) {
            if (!preg_match('/^b2bking_(regular_product_price|sale_product_price|product_pricetiers)_group_(.+)$/', (string) $row->meta_key, $m)) {
                continue;
            }
            [$kind, $gid] = [$m[1], $m[2]];

            if (!isset($wanted[$gid]) || $gid === 'b2c') {
                continue;
            }

            $byProductGroup[(int) $row->post_id][$gid][$kind] = (string) $row->meta_value;
        }

        foreach ($byProductGroup as $productId => $groups) {
            foreach ($groups as $gid => $data) {
                $this->emitProductPriceRules((int) $productId, (string) $gid, $data, $tiersArePercent, $rules);
            }
        }

        $this->warnPerUserProductPrices($warnings);
    }

    /**
     * @param array<string, string>            $data  regular_product_price / sale_product_price / product_pricetiers
     * @param array<int, array<string, mixed>> $rules
     */
    private function emitProductPriceRules(int $productId, string $gid, array $data, bool $tiersArePercent, array &$rules): void
    {
        // Fixed per-group price: the sale price is what the buyer pays if set,
        // otherwise the group regular price.
        $sale = isset($data['sale_product_price']) ? (float) $data['sale_product_price'] : 0.0;
        $reg  = isset($data['regular_product_price']) ? (float) $data['regular_product_price'] : 0.0;
        $fixed = $sale > 0 ? $sale : $reg;

        if ($fixed > 0) {
            $rules[] = [
                'source_key'      => 'gprice:' . $productId . ':fixed',
                'source_group_id' => $gid,
                'customer_id'     => null,
                'scope'           => PriceRule::SCOPE_PRODUCT,
                'object_id'       => $productId,
                'type'            => PriceRule::TYPE_FIXED,
                'value'           => $fixed,
                'min_qty'         => 1,
            ];
        }

        // Quantity price tiers: "qty:price;qty:price;".
        foreach (array_filter(explode(';', (string) ($data['product_pricetiers'] ?? ''))) as $tier) {
            $parts = explode(':', $tier);
            if (!isset($parts[1])) {
                continue;
            }

            $qty   = (int) $parts[0];
            $value = (float) $parts[1];

            if ($qty < 1 || $value <= 0) {
                continue;
            }

            $rules[] = [
                'source_key'      => 'gtier:' . $productId . ':q' . $qty,
                'source_group_id' => $gid,
                'customer_id'     => null,
                'scope'           => PriceRule::SCOPE_PRODUCT,
                'object_id'       => $productId,
                'type'            => $tiersArePercent ? PriceRule::TYPE_PERCENT : PriceRule::TYPE_FIXED,
                'value'           => $value,
                'min_qty'         => $qty,
            ];
        }
    }

    /**
     * B2BKing's product-level min / max / step (`b2bking_product_min_value` /
     * `_max_value` / `_step_value`) applies to every customer, not a group. Each
     * product with any of them becomes one all-customers product quantity rule.
     *
     * @param array<int, array<string, mixed>> $quantityRules
     */
    private function collectProductQuantities(array &$quantityRules): void
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta}
             WHERE meta_key IN ('b2bking_product_min_value', 'b2bking_product_max_value', 'b2bking_product_step_value')
               AND meta_value <> ''"
        );

        $byProduct = [];
        foreach ($rows as $row) {
            $byProduct[(int) $row->post_id][(string) $row->meta_key] = (int) $row->meta_value;
        }

        foreach ($byProduct as $productId => $data) {
            $min  = (int) ($data['b2bking_product_min_value'] ?? 0);
            $max  = (int) ($data['b2bking_product_max_value'] ?? 0);
            $step = (int) ($data['b2bking_product_step_value'] ?? 0);

            if ($min < 1 && $max < 1 && $step < 1) {
                continue;
            }

            $quantityRules[] = [
                'source_key'      => 'pqty:' . $productId,
                'source_group_id' => null, // all customers
                'scope'           => \SoftminalB2B\Models\QuantityRule::SCOPE_PRODUCT,
                'object_id'       => (int) $productId,
                'min_qty'         => $min > 0 ? $min : null,
                'max_qty'         => $max > 0 ? $max : null,
                'step_qty'        => $step > 0 ? $step : null,
                'min_order_value' => null,
            ];
        }
    }

    /**
     * B2BKing also supports per-user (not per-group) product prices/tiers. Our
     * per-customer scope is store-wide, so it can't price one product for one
     * user — report the count rather than silently dropping them.
     *
     * @param array<int, string> $warnings
     */
    private function warnPerUserProductPrices(array &$warnings): void
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->postmeta}
             WHERE meta_key LIKE 'b2bking_regular_price_user_%'
                OR meta_key LIKE 'b2bking_sale_price_user_%'
                OR meta_key LIKE 'b2bking_product_pricetiers_user_%'"
        );

        if ($count > 0) {
            $warnings[] = sprintf(
                /* translators: %d: number of per-customer product prices */
                _n(
                    '%d per-customer product price was found. B2B Suite prices per customer group (store-wide), not per product for one customer, so it was skipped — set these up by hand if needed.',
                    '%d per-customer product prices were found. B2B Suite prices per customer group (store-wide), not per product for one customer, so they were skipped — set these up by hand if needed.',
                    $count,
                    'softminal-b2b-wholesale-for-woocommerce'
                ),
                $count
            );
        }
    }

    /**
     * Resolve a who/applies value into a token list, following the
     * multiple_options indirection.
     *
     * @return array<int, string>
     */
    private function tokens(int $ruleId, string $singleKey, string $multiKey): array
    {
        $value = (string) get_post_meta($ruleId, $singleKey, true);

        if ($value === 'multiple_options') {
            $multi = (string) get_post_meta($ruleId, $multiKey, true);

            return array_values(array_filter(array_map('trim', explode(',', $multi))));
        }

        return $value === '' ? [] : [$value];
    }

    /**
     * @return array{0:string, 1:string} [kind, id]
     */
    private function parseToken(string $token): array
    {
        if (strpos($token, 'group_') === 0) {
            return ['group', substr($token, 6)];
        }
        if (strpos($token, 'user_') === 0) {
            return ['user', substr($token, 5)];
        }

        return [$token, ''];
    }

    /**
     * @return array{0:?string, 1:int} [scope, objectId]
     */
    private function parseApplies(string $token): array
    {
        if ($token === 'cart_total' || $token === 'all') {
            return [PriceRule::SCOPE_GLOBAL, 0];
        }
        if (strpos($token, 'product_') === 0) {
            return [PriceRule::SCOPE_PRODUCT, (int) substr($token, 8)];
        }
        if (strpos($token, 'category_') === 0) {
            return [PriceRule::SCOPE_CATEGORY, (int) substr($token, 9)];
        }

        return [null, 0];
    }
}
