<?php

namespace SoftminalB2B\Import;

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

use SoftminalB2B\Models\PriceRule;

/**
 * Imports from WholesaleX (H8).
 *
 * WholesaleX uses no custom tables. Its model lives entirely in core storage:
 *
 *   - Roles ("customer roles")     → option `_wholesalex_roles`
 *                                    (assoc, keyed by slug; `_role_title` label).
 *   - A user's role               → user meta `__wholesalex_role` (role slug).
 *   - A user's approval status    → user meta `__wholesalex_status`
 *                                    (`active` | `pending` | `inactive`).
 *   - Wholesale pricing rules     → option `__wholesalex_pricing_rules`
 *                                    (assoc, keyed by rule id — the modern
 *                                    "Wholesale Pricing" engine, mirroring the
 *                                    dynamic-rules storage pattern).
 *
 * A pricing rule carries its own targeting: `product_filter` (what it applies
 * to) × `user_role_filter` (who gets it) × a `regular` adjustment and optional
 * quantity `tiers`. That maps onto our (group_id × scope × type/value/min_qty).
 *
 * We read only core options/meta, so the import works with WholesaleX
 * deactivated and never writes to its keys. Faithful where the models line up
 * (percentage / amount-off / fixed price, per product / category / global,
 * per role / everyone / single user); honest where they don't — cart, BOGO and
 * buy-x-get-y rules, and brand/attribute/SKU targeting, are reported as
 * warnings rather than silently approximated.
 */
class WholesaleXImporter extends SourceImporter
{
    private const ROLES_OPTION = '_wholesalex_roles';
    private const RULES_OPTION  = '__wholesalex_pricing_rules';
    private const ROLE_META     = '__wholesalex_role';
    private const STATUS_META    = '__wholesalex_status';

    /**
     * Built-in WholesaleX roles that are not real B2B customer groups
     * (guests and default retail buyers) — skipped so the import stays clean.
     */
    private const SKIP_ROLES = ['wholesalex_guest', 'wholesalex_b2c_users'];

    public function slug(): string
    {
        return 'wholesalex';
    }

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

    public function isPresent(): bool
    {
        return !empty($this->roles()) || !empty($this->rules());
    }

    /**
     * Importable roles: slug => label, excluding WholesaleX's built-in
     * guest / B2C buckets.
     *
     * @return array<string, string>
     */
    private function roles(): array
    {
        $raw = maybe_unserialize(get_option(self::ROLES_OPTION, []));
        $out = [];

        if (is_array($raw)) {
            foreach ($raw as $slug => $info) {
                $slug = (string) $slug;

                if (in_array($slug, self::SKIP_ROLES, true)) {
                    continue;
                }

                $out[$slug] = is_array($info) && !empty($info['_role_title'])
                    ? (string) $info['_role_title']
                    : $slug;
            }
        }

        return $out;
    }

    /**
     * The raw `_wholesalex_roles` option (slug => role data, incl. `_restrictions`),
     * so the importer can read the per-role tax / quantity settings.
     *
     * @return array<string, array<string, mixed>>
     */
    private function rawRoles(): array
    {
        $raw = maybe_unserialize(get_option(self::ROLES_OPTION, []));

        return is_array($raw) ? $raw : [];
    }

    /**
     * Wholesale-pricing rules, keyed by rule id.
     *
     * @return array<string, array<string, mixed>>
     */
    private function rules(): array
    {
        $raw = maybe_unserialize(get_option(self::RULES_OPTION, []));

        return is_array($raw) ? $raw : [];
    }

    /**
     * @return array<string, mixed>
     */
    public function collect(): array
    {
        $roles             = $this->roles();
        $rawRoles          = $this->rawRoles();
        $groups            = [];
        $users             = [];
        $rules             = [];
        $quantity_rules    = [];
        $bogo_rules        = [];
        $tax_exempt_groups = [];
        $method_gating     = [];
        $companies         = [];
        $quotes            = [];
        $warnings          = [];

        foreach ($roles as $slug => $label) {
            $groups[] = ['source_id' => $slug, 'name' => $label];

            // Per-role checkout & tax settings live in the role's _restrictions.
            $restrictions = is_array($rawRoles[$slug]['_restrictions'] ?? null)
                ? $rawRoles[$slug]['_restrictions']
                : [];

            $this->collectRoleTax($slug, $restrictions, $tax_exempt_groups);
            $this->collectRoleQuantity($slug, $restrictions, $quantity_rules, $warnings);

            // Per-role shipping allow-list lives at the role's top level.
            $shipping = $rawRoles[$slug]['_shipping_methods'] ?? null;
            if (is_array($shipping) && !empty($shipping)) {
                $method_gating[] = [
                    'source_group_id' => $slug,
                    'shipping'        => array_values(array_unique(array_map('strval', array_filter($shipping)))),
                ];
            }
        }

        $this->collectUsers($roles, $users);

        foreach ($this->rules() as $ruleId => $rule) {
            if (is_array($rule)) {
                $this->collectRule((string) $ruleId, $rule, $roles, $rules, $warnings);
            }
        }

        $this->collectBogo($roles, $bogo_rules, $warnings);
        $this->collectCompanies($roles, $companies);
        $this->collectQuotes($quotes);

        if (empty($groups)) {
            $warnings[] = __('No WholesaleX customer roles were found to import.', 'softminal-b2b-wholesale-for-woocommerce');
        }

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

    /**
     * WholesaleX RAQ quotes (Pro add-on): the `wholesalex_quote` CPT, one per
     * customer (post_author), with line items in `__wholesalex_quote_data['items']`.
     *
     * @param array<int, array<string, mixed>> $quotes
     */
    private function collectQuotes(array &$quotes): void
    {
        global $wpdb;

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

        foreach ($rows as $row) {
            $userId = (int) $row->post_author;
            if ($userId <= 0) {
                continue;
            }

            $data     = get_post_meta((int) $row->ID, '__wholesalex_quote_data', true);
            $rawItems = (is_array($data) && isset($data['items']) && is_array($data['items'])) ? $data['items'] : [];

            $items = [];
            foreach ($rawItems as $item) {
                if (!is_array($item)) {
                    continue;
                }
                $productId = (int) ($item['product_id'] ?? 0);
                if ($productId <= 0) {
                    continue;
                }
                $items[] = [
                    'product_id'    => $productId,
                    'variation_id'  => (int) ($item['variation_id'] ?? 0) ?: null,
                    'quantity'      => max(1, (int) ($item['quantity'] ?? 1)),
                    'offered_price' => isset($item['unit_price']) && $item['unit_price'] !== '' ? (float) $item['unit_price'] : null,
                ];
            }

            if (empty($items)) {
                continue;
            }

            $quotes[] = [
                'source_key' => 'whxquote:' . $row->ID,
                'user_id'    => $userId,
                'status'     => $this->mapQuoteStatus((string) get_post_meta((int) $row->ID, '__wholesalex_quote_status', true)),
                'items'      => $items,
            ];
        }
    }

    /**
     * Map a WholesaleX quote status onto ours.
     */
    private function mapQuoteStatus(string $status): string
    {
        switch ($status) {
            case 'accepted':
                return 'accepted';
            case 'rejected':
            case 'declined':
                return 'declined';
            case 'expired':
                return 'expired';
            case 'offered':
            case 'make_offer':
                return 'offered';
            case 'pending':
            default:
                return 'submitted';
        }
    }

    /**
     * WholesaleX sub-accounts (Pro add-on): a parent user with sub-accounts
     * (`__wholesalex_parent_id` on each sub) → one company per parent, parent as
     * admin and sub-accounts as buyers. Mirrors the B2BKing company model.
     *
     * @param array<string, string>            $roles importable role slugs
     * @param array<int, array<string, mixed>> $companies
     */
    private function collectCompanies(array $roles, array &$companies): void
    {
        $subIds = get_users([
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-time migration read.
            'meta_key' => '__wholesalex_parent_id',
            'fields'   => 'ID',
        ]);

        $byParent = [];
        foreach ($subIds as $subId) {
            $parentId = (int) get_user_meta((int) $subId, '__wholesalex_parent_id', 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);

            $role = (string) get_user_meta($parentId, '__wholesalex_role', true);

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

            $companies[] = [
                'source_key'      => 'whxco:' . $parentId,
                'name'            => (string) $name,
                'source_group_id' => isset($roles[$role]) ? $role : null,
                'vat_number'      => null,
                'members'         => $companyMembers,
            ];
        }
    }

    /**
     * WholesaleX dynamic rules (`__wholesalex_dynamic_rules`) — a flat list.
     *
     * @return array<int, array<string, mixed>>
     */
    private function dynamicRules(): array
    {
        $raw = maybe_unserialize(get_option('__wholesalex_dynamic_rules', []));

        return is_array($raw) ? array_values(array_filter($raw, 'is_array')) : [];
    }

    /**
     * WholesaleX "Buy X get one free" dynamic rules → our BOGO rules (buy N of a
     * product/category, get 1 free of the same). One rule per target × role.
     *
     * @param array<string, string>            $roles slug => label (importable)
     * @param array<int, array<string, mixed>> $bogo
     * @param array<int, string>               $warnings
     */
    private function collectBogo(array $roles, array &$bogo, array &$warnings): void
    {
        foreach ($this->dynamicRules() as $index => $rule) {
            if (($rule['_rule_type'] ?? '') !== 'buy_x_get_one' || empty($rule['_rule_status'])) {
                continue;
            }

            $data   = is_array($rule['buy_x_get_one'] ?? null) ? $rule['buy_x_get_one'] : [];
            $buyQty = (int) ($data['_minimum_purchase_count'] ?? 0);
            if ($buyQty < 1) {
                continue;
            }

            $title    = (string) ($rule['_rule_title'] ?? 'WholesaleX BOGO');
            $ruleId   = (string) ($rule['id'] ?? $index);
            $repeat   = ($data['_per_cart_once'] ?? 'no') !== 'yes';
            $targets  = $this->bogoTargets($rule, $title, $warnings);
            $recipients = $this->bogoRecipients($rule, $roles, $title, $warnings);

            if (empty($targets) || empty($recipients)) {
                continue;
            }

            foreach ($targets as [$buyType, $buyId]) {
                foreach ($recipients as $recipient) {
                    $bogo[] = [
                        'source_key'        => 'whxbogo:' . $ruleId . ':' . $buyType . ':' . $buyId,
                        'source_group_id'   => $recipient,
                        'title'             => $title,
                        'buy_type'          => $buyType,
                        'buy_id'            => $buyId,
                        'buy_qty'           => $buyQty,
                        'reward_type'       => 'free',
                        'reward_qty'        => 1,
                        'reward_value'      => 0,
                        'reward_scope'      => 'same',
                        'reward_product_id' => 0,
                        'repeat_reward'     => $repeat,
                    ];
                }
            }
        }
    }

    /**
     * The buy targets of a dynamic rule → [buy_type, buy_id] pairs. "All products"
     * has no equivalent (a BOGO rule needs a concrete trigger) → warning.
     *
     * @param array<string, mixed> $rule
     * @param array<int, string>   $warnings
     * @return array<int, array{0:string, 1:int}>
     */
    private function bogoTargets(array $rule, string $title, array &$warnings): array
    {
        $filter = (string) ($rule['_product_filter'] ?? '');

        if ($filter === 'products_in_list') {
            return array_map(
                static fn ($id) => [\SoftminalB2B\Models\BogoRule::BUY_PRODUCT, (int) $id],
                $this->itemValues($rule['products_in_list'] ?? [])
            );
        }

        if ($filter === 'cat_in_list') {
            return array_map(
                static fn ($id) => [\SoftminalB2B\Models\BogoRule::BUY_CATEGORY, (int) $id],
                $this->itemValues($rule['cat_in_list'] ?? [])
            );
        }

        $warnings[] = sprintf(
            /* translators: 1: rule name, 2: filter */
            __('WholesaleX BOGO "%1$s" targets by "%2$s", which a BOGO rule here can’t use (it needs a specific product or category) — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
            $title,
            $filter ?: 'all products'
        );

        return [];
    }

    /**
     * The roles a dynamic rule is for → source group ids ('*' = every group).
     *
     * @param array<string, mixed>  $rule
     * @param array<string, string> $roles
     * @param array<int, string>    $warnings
     * @return array<int, string>
     */
    private function bogoRecipients(array $rule, array $roles, string $title, array &$warnings): array
    {
        $for = (string) ($rule['_rule_for'] ?? '');

        if (in_array($for, ['all', 'all_users', 'all_roles'], true)) {
            return ['*'];
        }

        if ($for === 'specific_roles') {
            $out = [];
            foreach ($this->itemValues($rule['specific_roles'] ?? [], 'string') as $value) {
                $slug = strpos((string) $value, 'role_') === 0 ? substr((string) $value, 5) : (string) $value;
                // WholesaleX stores either the slug or role_{slug}; accept whichever matches.
                if (isset($roles[$value])) {
                    $out[] = (string) $value;
                } elseif (isset($roles[$slug])) {
                    $out[] = $slug;
                }
            }
            return $out;
        }

        $warnings[] = sprintf(
            /* translators: 1: rule name, 2: audience */
            __('WholesaleX BOGO "%1$s" is for "%2$s", which is not mapped (BOGO applies per customer group) — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
            $title,
            $for ?: 'unknown'
        );

        return [];
    }

    /**
     * Read a WholesaleX restriction value, preferring the nested block it now
     * uses and falling back to the legacy flat key (mirrors WholesaleX's own
     * reader). $nestedPath is a dot path inside the nested block.
     *
     * @param array<string, mixed> $restrictions
     * @param string               $nestedKey   e.g. '_tax_rule' | '_checkout_restriction'
     * @param string               $nestedPath  e.g. 'tax_exempted' | 'quantity.min'
     * @param string               $flatKey     e.g. '_tax_exempted' | '_min_quantity'
     * @return mixed
     */
    private function restriction(array $restrictions, string $nestedKey, string $nestedPath, string $flatKey)
    {
        $nested = is_array($restrictions[$nestedKey] ?? null) ? $restrictions[$nestedKey] : [];
        $cursor = $nested;

        foreach (explode('.', $nestedPath) as $segment) {
            if (is_array($cursor) && array_key_exists($segment, $cursor)) {
                $cursor = $cursor[$segment];
            } else {
                $cursor = null;
                break;
            }
        }

        if ($cursor !== null && $cursor !== '') {
            return $cursor;
        }

        return $restrictions[$flatKey] ?? null;
    }

    private function isYes($value): bool
    {
        return in_array($value, ['yes', '1', 1, true], true);
    }

    /**
     * Role tax exemption → our per-group tax-exempt setting.
     *
     * @param array<string, mixed> $restrictions
     * @param array<int, string>   $taxExemptGroups
     */
    private function collectRoleTax(string $slug, array $restrictions, array &$taxExemptGroups): void
    {
        if ($this->isYes($this->restriction($restrictions, '_tax_rule', 'tax_exempted', '_tax_exempted'))) {
            $taxExemptGroups[] = $slug;
        }
    }

    /**
     * Role min/max quantity and min order value → one global quantity rule for
     * the role. Max order *value* has no equivalent (we cap quantity, not spend).
     *
     * @param array<string, mixed>             $restrictions
     * @param array<int, array<string, mixed>> $quantityRules
     * @param array<int, string>               $warnings
     */
    private function collectRoleQuantity(string $slug, array $restrictions, array &$quantityRules, array &$warnings): void
    {
        $minQty = null;
        $maxQty = null;
        $minVal = null;

        if ($this->isYes($this->restriction($restrictions, '_checkout_restriction', 'quantity.enabled', '_enable_min_max_quantity'))) {
            $min = (int) $this->restriction($restrictions, '_checkout_restriction', 'quantity.min', '_min_quantity');
            $max = (int) $this->restriction($restrictions, '_checkout_restriction', 'quantity.max', '_max_quantity');
            $minQty = $min > 0 ? $min : null;
            $maxQty = $max > 0 ? $max : null;
        }

        if ($this->isYes($this->restriction($restrictions, '_checkout_restriction', 'order_value.enabled', '_enable_min_max_order_value'))) {
            $min = (float) $this->restriction($restrictions, '_checkout_restriction', 'order_value.min', '_min_order_value');
            $minVal = $min > 0 ? $min : null;

            $max = (float) $this->restriction($restrictions, '_checkout_restriction', 'order_value.max', '_max_order_value');
            if ($max > 0) {
                $warnings[] = sprintf(
                    /* translators: %s: role name */
                    __('WholesaleX role "%s" caps the order *value*, which has no equivalent (we cap quantity, not spend) — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $slug
                );
            }
        }

        if ($minQty === null && $maxQty === null && $minVal === null) {
            return;
        }

        $quantityRules[] = [
            'source_key'      => 'whxqty:' . $slug,
            'source_group_id' => $slug,
            'scope'           => \SoftminalB2B\Models\QuantityRule::SCOPE_GLOBAL,
            'object_id'       => 0,
            'min_qty'         => $minQty,
            'max_qty'         => $maxQty,
            'step_qty'        => null,
            'min_order_value' => $minVal,
        ];
    }

    /**
     * @param array<string, string>            $roles
     * @param array<int, array<string, mixed>> $users
     */
    private function collectUsers(array $roles, array &$users): void
    {
        if (empty($roles)) {
            return;
        }

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

        foreach ($memberIds as $userId) {
            $sourceRole = (string) get_user_meta((int) $userId, self::ROLE_META, true);

            if ($sourceRole === '' || !isset($roles[$sourceRole])) {
                continue;
            }

            $users[] = [
                'user_id'         => (int) $userId,
                'source_group_id' => $sourceRole,
                'status'          => $this->mapStatus((string) get_user_meta((int) $userId, self::STATUS_META, true)),
            ];
        }
    }

    private function mapStatus(string $status): string
    {
        // WholesaleX: active | pending | inactive. Our applicant flow has no
        // "inactive", so a deactivated account is treated as still pending.
        return $status === 'active' ? 'approved' : 'pending';
    }

    /**
     * @param array<string, mixed>             $rule
     * @param array<string, string>            $roles
     * @param array<int, array<string, mixed>> $rules
     * @param array<int, string>               $warnings
     */
    private function collectRule(string $ruleId, array $rule, array $roles, array &$rules, array &$warnings): void
    {
        $ruleType = (string) ($rule['rule_type'] ?? 'wholesale_pricing');
        $title    = (string) ($rule['title'] ?? $ruleId);

        // Only the price-adjustment rule types map to our per-line price rules.
        if (!in_array($ruleType, ['wholesale_pricing', 'product_discount'], true)) {
            $warnings[] = sprintf(
                /* translators: 1: rule name, 2: rule type */
                __('WholesaleX rule "%1$s" is a "%2$s" rule, which has no equivalent yet — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                $title,
                $ruleType
            );
            return;
        }

        // Inactive rules import as nothing (we only ever create active rules).
        if (($rule['status'] ?? 'inactive') !== 'active') {
            $warnings[] = sprintf(
                /* translators: %s: rule name */
                __('WholesaleX rule "%s" is inactive — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                $title
            );
            return;
        }

        $targets = $this->targets($rule, $title, $warnings);
        if (empty($targets)) {
            return;
        }

        $recipients = $this->recipients($rule, $roles, $title, $warnings);
        if (empty($recipients)) {
            return;
        }

        // A rule's adjustments = the "regular" tier (qty 1) plus any quantity
        // tiers. Each becomes a price rule per target × recipient.
        foreach ($this->adjustments($rule) as $adj) {
            foreach ($targets as $target) {
                foreach ($recipients as $recipient) {
                    $this->emit($ruleId, $target, $recipient, $adj, $rules);
                }
            }
        }
    }

    /**
     * What the rule applies to → a list of [scope, object_id].
     *
     * @param array<string, mixed> $rule
     * @param array<int, string>   $warnings
     * @return array<int, array{0:string, 1:int}>
     */
    private function targets(array $rule, string $title, array &$warnings): array
    {
        $filter = (string) ($rule['product_filter'] ?? 'all_products');

        if ($filter === 'all_products') {
            return [[PriceRule::SCOPE_GLOBAL, 0]];
        }

        if ($filter === 'specific_products') {
            return array_map(
                static fn ($id) => [PriceRule::SCOPE_PRODUCT, $id],
                $this->itemValues($rule['products'] ?? [])
            );
        }

        if ($filter === 'specific_categories') {
            return array_map(
                static fn ($id) => [PriceRule::SCOPE_CATEGORY, $id],
                $this->itemValues($rule['categories'] ?? [])
            );
        }

        $warnings[] = sprintf(
            /* translators: 1: rule name, 2: filter type */
            __('WholesaleX rule "%1$s" targets by %2$s, which is not supported yet — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
            $title,
            $filter
        );

        return [];
    }

    /**
     * Who the rule is for → a list of recipient descriptors:
     *   ['group' => source_role_slug] | ['group' => '*'] | ['customer' => user_id]
     *
     * @param array<string, mixed>  $rule
     * @param array<string, string> $roles
     * @param array<int, string>    $warnings
     * @return array<int, array<string, mixed>>
     */
    private function recipients(array $rule, array $roles, string $title, array &$warnings): array
    {
        $filter = (string) ($rule['user_role_filter'] ?? 'all_b2b');

        // "Everyone" variants fan out to every imported B2B group.
        if (in_array($filter, ['all', 'all_users', 'all_b2b'], true)) {
            return [['group' => '*']];
        }

        if ($filter === 'specific_roles') {
            $out = [];

            foreach ($this->itemValues($rule['user_roles'] ?? [], 'string') as $slug) {
                if (isset($roles[$slug])) {
                    $out[] = ['group' => (string) $slug];
                }
            }

            if (empty($out)) {
                $warnings[] = sprintf(
                    /* translators: %s: rule name */
                    __('WholesaleX rule "%s" targets only roles that were not imported — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $title
                );
            }

            return $out;
        }

        if ($filter === 'specific_users') {
            return array_map(
                static fn ($id) => ['customer' => (int) $id],
                $this->itemValues($rule['specific_users'] ?? [])
            );
        }

        $warnings[] = sprintf(
            /* translators: 1: rule name, 2: filter type */
            __('WholesaleX rule "%1$s" is for "%2$s", which is not mapped — skipped.', 'softminal-b2b-wholesale-for-woocommerce'),
            $title,
            $filter
        );

        return [];
    }

    /**
     * Regular adjustment + quantity tiers → [type, value, min_qty] rows.
     *
     * @param array<string, mixed> $rule
     * @return array<int, array{type:string, value:float, min_qty:int}>
     */
    private function adjustments(array $rule): array
    {
        $out = [];

        $regular = is_array($rule['regular'] ?? null) ? $rule['regular'] : [];
        if (($regular['amount'] ?? '') !== '') {
            $out[] = [
                'type'    => $this->mapType((string) ($regular['amount_type'] ?? 'percentage')),
                'value'   => (float) $regular['amount'],
                'min_qty' => 1,
            ];
        }

        foreach ((array) ($rule['tiers'] ?? []) as $tier) {
            if (!is_array($tier) || ($tier['amount'] ?? '') === '') {
                continue;
            }

            $out[] = [
                'type'    => $this->mapType((string) ($tier['amount_type'] ?? 'percentage')),
                'value'   => (float) $tier['amount'],
                'min_qty' => max(1, (int) ($tier['min_qty'] ?? 1)),
            ];
        }

        return $out;
    }

    private function mapType(string $amountType): string
    {
        switch ($amountType) {
            case 'amount':
                return PriceRule::TYPE_AMOUNT_OFF;
            case 'fixed':
                return PriceRule::TYPE_FIXED;
            case 'percentage':
            default:
                return PriceRule::TYPE_PERCENT;
        }
    }

    /**
     * @param array{0:string, 1:int}          $target
     * @param array<string, mixed>            $recipient
     * @param array{type:string, value:float, min_qty:int} $adj
     * @param array<int, array<string, mixed>> $rules
     */
    private function emit(string $ruleId, array $target, array $recipient, array $adj, array &$rules): void
    {
        [$scope, $objectId] = $target;

        $key = 'whx:' . $ruleId . ':' . $scope . ':' . $objectId . ':q' . $adj['min_qty'] . ':' . $adj['type'];

        if (isset($recipient['customer'])) {
            // A single-user rule maps to our store-wide customer scope, so it
            // cannot also narrow to one product/category.
            if ($scope !== PriceRule::SCOPE_GLOBAL) {
                return;
            }

            $rules[] = [
                'source_key'      => $key . ':user:' . $recipient['customer'],
                'source_group_id' => null,
                'customer_id'     => (int) $recipient['customer'],
                'scope'           => PriceRule::SCOPE_CUSTOMER,
                'object_id'       => (int) $recipient['customer'],
                'type'            => $adj['type'],
                'value'           => $adj['value'],
                'min_qty'         => $adj['min_qty'],
            ];
            return;
        }

        $rules[] = [
            'source_key'      => $key,
            'source_group_id' => $recipient['group'],
            'customer_id'     => null,
            'scope'           => $scope,
            'object_id'       => $objectId,
            'type'            => $adj['type'],
            'value'           => $adj['value'],
            'min_qty'         => $adj['min_qty'],
        ];
    }

    /**
     * Pull integer/string values out of WholesaleX's MultiSelect {name, value}
     * item lists (it also tolerates a plain scalar list).
     *
     * @param mixed  $items
     * @param string $cast 'int' | 'string'
     * @return array<int, int|string>
     */
    private function itemValues($items, string $cast = 'int'): array
    {
        $out = [];

        foreach ((array) $items as $item) {
            $value = is_array($item) ? ($item['value'] ?? null) : $item;

            if ($value === null || $value === '') {
                continue;
            }

            $out[] = $cast === 'string' ? (string) $value : (int) $value;
        }

        return array_values(array_filter($out, static fn ($v) => $v !== 0 && $v !== ''));
    }
}
