<?php

namespace SoftminalB2B\Pricing;

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

use SoftminalB2B\Compat\Multilingual;
use SoftminalB2B\Install;
use SoftminalB2B\Models\PriceRule;
use SoftminalB2B\Support\Tier;

/**
 * The single entry point for every B2B price. Nothing else in the plugin may
 * mutate a WooCommerce price.
 *
 * Walks the precedence order in FLOWS §7, highest first, first match wins:
 *   1 quote-lock  2 customer  3 company  4 group+product
 *   5 group+taxonomy  6 group+global  7 Woo's own price
 *
 * Levels 1 and 3 have no rule source in core; the Pro add-on supplies them
 * through the `smb2b_pricing_level_{level}` filters rather than by patching
 * this class. Rule membership is re-read on every resolution and never
 * snapshotted — snapshotting is the documented WholesaleX bug where products
 * added to a list later are silently ignored.
 *
 * Coupons are deliberately outside this class (FEATURES A6): they apply after
 * resolution, on the resolved price, and never re-enter the walk.
 */
class Resolver
{
    private const CACHE_GROUP    = 'smb2b_pricing';
    private const VERSION_OPTION = 'smb2b_price_cache_version';

    /**
     * Set while we ask WooCommerce for its own untouched price, so our filters
     * hand back the raw value instead of recursing.
     */
    private static bool $readingBasePrice = false;

    /** @var array<string, ResolvedPrice> */
    private static array $requestMemo = [];

    /** @var array<string, array<int, array<string, mixed>>|null> */
    private static array $ruleSetMemo = [];

    /** @var array<int, bool> Per-request memo of "buyer has a customer-scoped rule". */
    private static array $customerRuleMemo = [];

    /** Per-request memo of "any active customer-scoped rule exists store-wide". */
    private static ?bool $anyCustomerRuleMemo = null;

    public static function isReadingBasePrice(): bool
    {
        return self::$readingBasePrice;
    }

    /**
     * Resolve the B2B price for one product/variation, buyer and quantity.
     */
    public static function resolve(int $productId, ?int $variationId, ?int $userId = null, int $qty = 1): ResolvedPrice
    {
        $context = Context::forUser($userId);
        $qty     = max(1, $qty);

        $product = wc_get_product($variationId ?: $productId);

        if (!$product) {
            return new ResolvedPrice(0.0, 0.0, ResolvedPrice::SOURCE_WOO, null, $qty, []);
        }

        $basePrice    = self::basePrice($product);
        $regularPrice = self::regularPrice($product);

        // Customer-scoped rules are group-independent (FLOWS §7, precedence 2),
        // so a logged-in buyer with no group can still have one. When they do,
        // the price is per-person and the cache key must be salted with the user
        // id — otherwise one buyer's private price would be served to another
        // buyer sharing the same group/company segment.
        $userSalt = self::userSalt($context);

        // Guests never touch the rule tables; nor does a logged-in retail buyer
        // unless they carry a customer-scoped rule (the salt above proves it).
        if ($context->isRetail() && $userSalt === 0) {
            return new ResolvedPrice($basePrice, $basePrice, ResolvedPrice::SOURCE_WOO, null, $qty, []);
        }

        $memoKey = self::memoKey($productId, $variationId, $context, $qty, $userSalt);

        if (isset(self::$requestMemo[$memoKey])) {
            return self::$requestMemo[$memoKey];
        }

        $cacheKey = self::cacheKey($productId, $variationId, $context, $qty, $userSalt);
        $cached   = wp_cache_get($cacheKey, self::CACHE_GROUP);

        if (is_array($cached) && isset($cached['price'])) {
            $resolved = new ResolvedPrice(
                (float) $cached['price'],
                (float) $cached['base_price'],
                (string) $cached['source'],
                $cached['rule_id'] === null ? null : (int) $cached['rule_id'],
                $qty,
                is_array($cached['trail']) ? $cached['trail'] : []
            );

            return self::$requestMemo[$memoKey] = self::finalize($resolved, $productId, $variationId, $context, $qty);
        }

        $resolved = self::walk($productId, $variationId, $context, $qty, $basePrice, $regularPrice);

        wp_cache_set(
            $cacheKey,
            [
                'price'      => $resolved->price(),
                'base_price' => $resolved->basePrice(),
                'source'     => $resolved->source(),
                'rule_id'    => $resolved->ruleId(),
                'trail'      => $resolved->trail(),
            ],
            self::CACHE_GROUP,
            HOUR_IN_SECONDS
        );

        return self::$requestMemo[$memoKey] = self::finalize($resolved, $productId, $variationId, $context, $qty);
    }

    /**
     * The last word on a price before it is used anywhere.
     *
     * Runs on cache hits as well as fresh walks, so a developer's override is
     * never skipped by caching. The per-request memo stores the filtered
     * result, which is safe because the filter set cannot change mid-request.
     */
    private static function finalize(ResolvedPrice $resolved, int $productId, ?int $variationId, Context $context, int $qty): ResolvedPrice
    {
        /**
         * Filter the fully resolved price for one product and buyer.
         *
         * Return a replacement ResolvedPrice to change what is charged and
         * shown everywhere, including the cart and the Price Explainer.
         *
         * @param ResolvedPrice $resolved
         * @param array{product_id:int, variation_id:int|null, qty:int, user_id:int, group_id:int|null, company_id:int|null} $request
         */
        $filtered = apply_filters('smb2b_resolved_price', $resolved, [
            'product_id'   => $productId,
            'variation_id' => $variationId,
            'qty'          => $qty,
            'user_id'      => $context->userId(),
            'group_id'     => $context->groupId(),
            'company_id'   => $context->companyId(),
        ]);

        return $filtered instanceof ResolvedPrice ? $filtered : $resolved;
    }

    /**
     * The precedence walk. One indexed query feeds every core level.
     */
    private static function walk(
        int $productId,
        ?int $variationId,
        Context $context,
        int $qty,
        float $basePrice,
        float $regularPrice
    ): ResolvedPrice {
        $trail = [];
        $rules = self::candidateRules($productId, $variationId, $context);

        foreach (self::levels() as $level) {
            $match = null;

            // Quote-lock and company price-list levels have no rule source in
            // Free — that data only exists once the Pro add-on is active — so
            // Free skips walking them. Every group-scoped level (customer,
            // product, category, tag, global) resolves in Free. The walk stays
            // in core because all rule sources share one indexed query.
            if (!Tier::isPro() && in_array($level, self::proLevels(), true)) {
                continue;
            }

            switch ($level) {
                case ResolvedPrice::SOURCE_CUSTOMER:
                    $match = self::bestRule($rules[PriceRule::SCOPE_CUSTOMER] ?? [], $qty, $regularPrice);
                    break;

                case ResolvedPrice::SOURCE_GROUP_PRODUCT:
                    $match = self::bestRule($rules[PriceRule::SCOPE_PRODUCT] ?? [], $qty, $regularPrice);
                    break;

                case ResolvedPrice::SOURCE_GROUP_TAXONOMY:
                    $match = self::bestTaxonomyRule($rules, $productId, $variationId, $qty, $regularPrice);
                    break;

                case ResolvedPrice::SOURCE_GROUP_GLOBAL:
                    $match = self::bestRule($rules[PriceRule::SCOPE_GLOBAL] ?? [], $qty, $regularPrice);
                    break;
            }

            /**
             * Extension point for Pro rule sources (quote locks, company price
             * lists, and anything later). Returning an array with a `price` key
             * wins the walk at this level.
             *
             * @param array{price:float, rule_id:int|null, detail:string}|null $match   The core match, if any.
             * @param array<string, mixed>                                     $request Product, buyer, quantity and base prices.
             */
            $match = apply_filters(
                'smb2b_pricing_level_' . $level,
                $match,
                [
                    'product_id'   => $productId,
                    'variation_id' => $variationId,
                    'context'      => $context,
                    'qty'          => $qty,
                    'base_price'   => $basePrice,
                    'regular_price' => $regularPrice,
                ]
            );

            if (is_array($match) && isset($match['price'])) {
                $trail[] = [
                    'level'   => $level,
                    'outcome' => 'applied',
                    'detail'  => (string) ($match['detail'] ?? ''),
                    'rule_id' => isset($match['rule_id']) ? (int) $match['rule_id'] : null,
                ];

                $price = self::clampPrice((float) $match['price']);

                // A rule matched, but a cheaper one at the same level may have
                // been blocked purely by quantity. Saying so is the whole point
                // of A7 ("the 10+ tier wasn't reached") and it only makes sense
                // next to the price that actually applied.
                $nearMiss = self::nearMissTier($level, $rules, $qty, $regularPrice, $price);

                if ($nearMiss !== null) {
                    $trail[] = [
                        'level'   => $level,
                        'outcome' => 'noted',
                        'detail'  => $nearMiss,
                        'rule_id' => null,
                    ];
                }

                // Record, but do not act on, a lower Woo sale price: precedence
                // says the matched rule wins. Surfacing it here lets the A7
                // Explainer answer "why is my sale price not showing?".
                if ($basePrice < $price) {
                    $trail[] = [
                        'level'   => ResolvedPrice::SOURCE_WOO,
                        'outcome' => 'noted',
                        'detail'  => sprintf(
                            /* translators: %s: formatted shop price */
                            __('The shop price of %s is lower, but the B2B rule above takes priority.', 'softminal-b2b-wholesale-for-woocommerce'),
                            self::formatPrice($basePrice)
                        ),
                        'rule_id' => null,
                    ];
                }

                return new ResolvedPrice(
                    $price,
                    $basePrice,
                    $level,
                    isset($match['rule_id']) ? (int) $match['rule_id'] : null,
                    $qty,
                    $trail
                );
            }

            $skipped = self::skipReason($level, $rules, $qty, $regularPrice, $basePrice);

            if ($skipped !== null) {
                $trail[] = [
                    'level'   => $level,
                    'outcome' => 'skipped',
                    'detail'  => $skipped,
                    'rule_id' => null,
                ];
            }
        }

        $trail[] = [
            'level'   => ResolvedPrice::SOURCE_WOO,
            'outcome' => 'applied',
            'detail'  => __('No B2B rule applied, so the normal shop price is used.', 'softminal-b2b-wholesale-for-woocommerce'),
            'rule_id' => null,
        ];

        return new ResolvedPrice($basePrice, $basePrice, ResolvedPrice::SOURCE_WOO, null, $qty, $trail);
    }

    /**
     * Levels whose rule *data* only exists once Pro is active: quote locks and
     * company price lists. Free skips walking these because they can never have
     * a match. Every other level — customer, group+product, group+taxonomy
     * (category/tag) and group+global — resolves in Free. Core still names these
     * two so the precedence order lives in one list and Pro extends the walk
     * through filters instead of patching it.
     *
     * @return string[]
     */
    public static function proLevels(): array
    {
        return [
            ResolvedPrice::SOURCE_QUOTE,
            ResolvedPrice::SOURCE_COMPANY,
            // Vendor-scoped pricing ships in the Pro-gated Dokan bridge. Its rule
            // data can never exist in Free, and the level resolves entirely
            // through `smb2b_pricing_level_vendor` (no core `switch` case), so
            // Free skips walking it.
            ResolvedPrice::SOURCE_VENDOR,
        ];
    }

    /**
     * Precedence order, highest first.
     *
     * @return string[]
     */
    public static function levels(): array
    {
        return [
            ResolvedPrice::SOURCE_QUOTE,
            ResolvedPrice::SOURCE_CUSTOMER,
            ResolvedPrice::SOURCE_COMPANY,
            ResolvedPrice::SOURCE_GROUP_PRODUCT,
            ResolvedPrice::SOURCE_GROUP_TAXONOMY,
            // A vendor-catalogue rule is broad (all of one vendor's products), so
            // it sits just above the store-wide global band but below any
            // product/taxonomy rule. Core has no `switch` case for it; the Dokan
            // bridge resolves it via `smb2b_pricing_level_vendor`.
            ResolvedPrice::SOURCE_VENDOR,
            ResolvedPrice::SOURCE_GROUP_GLOBAL,
        ];
    }

    /**
     * Every rule that could possibly apply, in one indexed query (the cost
     * budget is <=1 query on a cache miss, 0 on a hit).
     *
     * @return array<string, array<int, array<string, mixed>>> keyed by scope
     */
    private static function candidateRules(int $productId, ?int $variationId, Context $context): array
    {
        global $wpdb;

        $table   = Install::tableName('price_rules');
        $groupId = $context->groupId();
        $userId  = $context->userId();

        // Match against the default-language ids so a rule created on the
        // original product applies to every translation of it.
        $objectIds = Multilingual::productIds(array_filter([$productId, $variationId]));

        // A 300-variation product would otherwise run 300 identical-shaped
        // queries to render one page. When a group's rule set is small enough
        // to hold in memory (the overwhelmingly common case) we load it once
        // per request and filter in PHP, so the whole page costs one query.
        $bulk = self::groupRuleSet($groupId, $userId);

        if ($bulk !== null) {
            return self::indexRules($bulk, $objectIds);
        }
        $objectPlaceholders = implode(', ', array_fill(0, count($objectIds), '%d'));

        // Group-scoped rules (global/product/category/tag) OR this customer's
        // own rules, which are group-independent.
        $sql = "SELECT id, group_id, scope, object_id, type, value, min_qty, max_qty, priority
                FROM `{$table}`
                WHERE active = 1
                  AND (
                        (group_id = %d AND (
                            scope = 'global'
                            OR (scope IN ('product') AND object_id IN ({$objectPlaceholders}))
                            OR scope IN ('category', 'tag')
                        ))
                        OR (scope = 'customer' AND object_id = %d)
                  )";

        $params = array_merge([(int) $groupId], $objectIds, [(int) $userId]);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);

        $byScope = [];

        foreach ($rows ?: [] as $row) {
            $byScope[$row['scope']][] = $row;
        }

        return $byScope;
    }

    /**
     * Every active rule visible to this buyer, loaded once per request.
     *
     * Returns null for stores whose rule set is too large to hold in memory,
     * in which case the caller falls back to a per-product query.
     *
     * @return array<int, array<string, mixed>>|null
     */
    private static function groupRuleSet(?int $groupId, int $userId): ?array
    {
        $key = sprintf('%d:%d', (int) $groupId, $userId);

        if (array_key_exists($key, self::$ruleSetMemo)) {
            return self::$ruleSetMemo[$key];
        }

        $cacheKey = sprintf('smb2b_ruleset:%d:%d:%d', self::version(), (int) $groupId, $userId);
        $cached   = wp_cache_get($cacheKey, self::CACHE_GROUP);

        if (is_array($cached)) {
            return self::$ruleSetMemo[$key] = ($cached['rules'] ?? null);
        }

        global $wpdb;

        $table = Install::tableName('price_rules');

        /**
         * Above this many rules for one buyer, bulk-loading stops being a win
         * and the resolver reverts to targeted per-product queries.
         */
        $threshold = (int) apply_filters('smb2b_rule_set_bulk_threshold', 2000);

        $sql = "SELECT id, group_id, scope, object_id, type, value, min_qty, max_qty, priority
                FROM `{$table}`
                WHERE active = 1
                  AND (group_id = %d OR (scope = 'customer' AND object_id = %d))
                LIMIT %d";

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $rows = $wpdb->get_results($wpdb->prepare($sql, (int) $groupId, $userId, $threshold + 1), ARRAY_A);

        $rows = $rows ?: [];

        // Hit the limit? The set is too big to trust as complete, so fall back.
        $rules = count($rows) > $threshold ? null : $rows;

        wp_cache_set($cacheKey, ['rules' => $rules], self::CACHE_GROUP, HOUR_IN_SECONDS);

        return self::$ruleSetMemo[$key] = $rules;
    }

    /**
     * Group a bulk-loaded rule set by scope, keeping only the product-scoped
     * rules that target this product or variation.
     *
     * @param array<int, array<string, mixed>> $rules
     * @param array<int, int> $objectIds
     * @return array<string, array<int, array<string, mixed>>>
     */
    private static function indexRules(array $rules, array $objectIds): array
    {
        $byScope = [];

        foreach ($rules as $rule) {
            if ($rule['scope'] === PriceRule::SCOPE_PRODUCT
                && !in_array((int) $rule['object_id'], $objectIds, true)) {
                continue;
            }

            $byScope[$rule['scope']][] = $rule;
        }

        return $byScope;
    }

    /**
     * Rank a set of raw rule rows and return the winning price, exactly as the
     * core precedence walk does — quantity band must match, ties broken by
     * `priority` then the larger discount.
     *
     * Exposed so an external precedence level added through
     * `smb2b_pricing_level_{level}` (the Dokan bridge's `vendor` level) ranks with
     * identical money math and tie-breaking instead of duplicating it. Rows must
     * carry the same columns as the `price_rules` table (`id`, `type`, `value`,
     * `min_qty`, `max_qty`, `priority`). Returns the winning
     * `['price','rule_id','detail']`, or null when nothing matches.
     *
     * @param array<int, array<string, mixed>> $rules
     * @return array{price:float, rule_id:int, detail:string}|null
     */
    public static function bestOfRules(array $rules, int $qty, float $regularPrice): ?array
    {
        return self::bestRule($rules, $qty, $regularPrice);
    }

    /**
     * Best rule within one level: quantity band must match; ties broken by
     * `priority` then by the larger discount.
     *
     * @param array<int, array<string, mixed>> $rules
     * @return array{price:float, rule_id:int, detail:string}|null
     */
    private static function bestRule(array $rules, int $qty, float $regularPrice): ?array
    {
        $best      = null;
        $bestPrice = null;

        foreach ($rules as $rule) {
            if (!self::quantityMatches($rule, $qty)) {
                continue;
            }

            $price = self::applyRule($rule, $regularPrice);

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

            if ($best === null) {
                $best      = $rule;
                $bestPrice = $price;
                continue;
            }

            $priority     = (int) $rule['priority'];
            $bestPriority = (int) $best['priority'];

            if ($priority > $bestPriority || ($priority === $bestPriority && $price < $bestPrice)) {
                $best      = $rule;
                $bestPrice = $price;
            }
        }

        if ($best === null || $bestPrice === null) {
            return null;
        }

        return [
            'price'   => $bestPrice,
            'rule_id' => (int) $best['id'],
            'detail'  => self::describeRule($best, $regularPrice, $bestPrice, $qty),
        ];
    }

    /**
     * Category and tag rules. Most specific taxonomy wins; a tie goes to the
     * larger discount. Costs an extra term lookup, so it is skipped entirely
     * when the group has no taxonomy rules — which is always true in Free.
     *
     * @param array<string, array<int, array<string, mixed>>> $rules
     * @return array{price:float, rule_id:int, detail:string}|null
     */
    private static function bestTaxonomyRule(array $rules, int $productId, ?int $variationId, int $qty, float $regularPrice): ?array
    {
        $categoryRules = $rules[PriceRule::SCOPE_CATEGORY] ?? [];
        $tagRules      = $rules[PriceRule::SCOPE_TAG] ?? [];

        if (empty($categoryRules) && empty($tagRules)) {
            return null;
        }

        // Variations inherit their parent's terms.
        $termProductId = $productId;

        // Map the product's (possibly translated) terms back to the default
        // language so a category/tag rule matches across every translation.
        $categoryIds = Multilingual::termIds(wc_get_product_term_ids($termProductId, 'product_cat'), 'product_cat');
        $tagIds      = Multilingual::termIds(wc_get_product_term_ids($termProductId, 'product_tag'), 'product_tag');

        // A tag is treated as more specific than a category.
        $tagMatches = array_values(array_filter($tagRules, static function ($rule) use ($tagIds) {
            return in_array((int) $rule['object_id'], $tagIds, true);
        }));

        $match = self::bestRule($tagMatches, $qty, $regularPrice);

        if ($match !== null) {
            return $match;
        }

        $categoryMatches = array_values(array_filter($categoryRules, static function ($rule) use ($categoryIds) {
            return in_array((int) $rule['object_id'], $categoryIds, true);
        }));

        return self::bestRule($categoryMatches, $qty, $regularPrice);
    }

    /**
     * @param array<string, mixed> $rule
     */
    private static function quantityMatches(array $rule, int $qty): bool
    {
        $min = (int) ($rule['min_qty'] ?? 1);
        $max = $rule['max_qty'] === null ? null : (int) $rule['max_qty'];

        return $qty >= $min && ($max === null || $qty <= $max);
    }

    /**
     * @param array<string, mixed> $rule
     */
    private static function applyRule(array $rule, float $regularPrice): ?float
    {
        $value = (float) $rule['value'];

        switch ($rule['type']) {
            case PriceRule::TYPE_FIXED:
                // A fixed price is an explicit figure the merchant typed, so it
                // is honoured as-is (including a deliberate 0 for a free line).
                return self::clampPrice($value);

            case PriceRule::TYPE_PERCENT:
                // Percentage and amount-off rules discount the regular price, so
                // with no positive regular price there is nothing to discount.
                // Applying them anyway resolves to 0 and sells the product free —
                // the reported failure for imported goods that carry only an
                // active price. Skip the rule instead and let the walk fall
                // through to the next level or Woo's own price.
                if ($regularPrice <= 0) {
                    return null;
                }

                return self::clampPrice($regularPrice - ($regularPrice * ($value / 100)));

            case PriceRule::TYPE_AMOUNT_OFF:
                if ($regularPrice <= 0) {
                    return null;
                }

                return self::clampPrice($regularPrice - $value);
        }

        return null;
    }

    /**
     * Plain-language description of one applied rule, for the Explainer.
     *
     * @param array<string, mixed> $rule
     */
    private static function describeRule(array $rule, float $regularPrice, float $price, int $qty): string
    {
        $minQty = (int) ($rule['min_qty'] ?? 1);

        switch ($rule['type']) {
            case PriceRule::TYPE_PERCENT:
                $detail = sprintf(
                    /* translators: 1: discount percentage, 2: formatted price */
                    __('A %1$s%% B2B discount applied, bringing the price to %2$s.', 'softminal-b2b-wholesale-for-woocommerce'),
                    (float) $rule['value'],
                    self::formatPrice($price)
                );
                break;

            case PriceRule::TYPE_AMOUNT_OFF:
                $detail = sprintf(
                    /* translators: 1: formatted discount, 2: formatted price */
                    __('%1$s off the regular price, bringing it to %2$s.', 'softminal-b2b-wholesale-for-woocommerce'),
                    self::formatPrice((float) $rule['value']),
                    self::formatPrice($price)
                );
                break;

            default:
                $detail = sprintf(
                    /* translators: %s: formatted price */
                    __('A fixed B2B price of %s applied.', 'softminal-b2b-wholesale-for-woocommerce'),
                    self::formatPrice($price)
                );
        }

        // The rule already matched, so state which tier it is rather than
        // repeating a quantity condition the buyer has plainly met.
        if ($minQty > 1) {
            $detail .= ' ' . sprintf(
                /* translators: %d: minimum quantity for this tier */
                __('That is the %d-or-more price.', 'softminal-b2b-wholesale-for-woocommerce'),
                $minQty
            );
        }

        return $detail;
    }

    /**
     * The cheapest rule at this level that the buyer just missed on quantity,
     * described in plain language. Null when nothing better is within reach.
     *
     * @param array<string, array<int, array<string, mixed>>> $rules
     */
    private static function nearMissTier(string $level, array $rules, int $qty, float $regularPrice, float $appliedPrice): ?string
    {
        $scope = self::scopeForLevel($level);

        if ($scope === null || empty($rules[$scope])) {
            return null;
        }

        $bestTier  = null;
        $bestPrice = null;

        foreach ($rules[$scope] as $rule) {
            $min = (int) ($rule['min_qty'] ?? 1);

            if ($min <= $qty) {
                continue;
            }

            $price = self::applyRule($rule, $regularPrice);

            if ($price === null || $price >= $appliedPrice) {
                continue;
            }

            if ($bestTier === null || $min < $bestTier || ($min === $bestTier && $price < $bestPrice)) {
                $bestTier  = $min;
                $bestPrice = $price;
            }
        }

        if ($bestTier === null || $bestPrice === null) {
            return null;
        }

        return sprintf(
            /* translators: 1: quantity needed, 2: formatted price, 3: current quantity */
            __('Buy %1$d or more to pay %2$s each — you have %3$d, so that price was not applied.', 'softminal-b2b-wholesale-for-woocommerce'),
            $bestTier,
            self::formatPrice($bestPrice),
            $qty
        );
    }

    private static function scopeForLevel(string $level): ?string
    {
        switch ($level) {
            case ResolvedPrice::SOURCE_CUSTOMER:
                return PriceRule::SCOPE_CUSTOMER;
            case ResolvedPrice::SOURCE_GROUP_PRODUCT:
                return PriceRule::SCOPE_PRODUCT;
            case ResolvedPrice::SOURCE_GROUP_GLOBAL:
                return PriceRule::SCOPE_GLOBAL;
        }

        return null;
    }

    /**
     * Why a level produced nothing — the most useful half of the Explainer,
     * e.g. "the 10+ quantity tier wasn't reached".
     *
     * @param array<string, array<int, array<string, mixed>>> $rules
     */
    private static function skipReason(string $level, array $rules, int $qty, float $regularPrice, float $basePrice): ?string
    {
        $scope = self::scopeForLevel($level);

        if ($scope === null || empty($rules[$scope])) {
            return null;
        }

        // Rules exist at this level but none matched the quantity. Only offer a
        // higher tier that genuinely beats the current shop price: a fixed
        // higher-quantity tier priced at or above what the buyer would pay
        // anyway is not "a better price" and must not be advertised as one.
        $nextTier = null;

        foreach ($rules[$scope] as $rule) {
            $min = (int) ($rule['min_qty'] ?? 1);

            if ($min <= $qty) {
                continue;
            }

            $price = self::applyRule($rule, $regularPrice);

            if ($price === null || $price >= $basePrice) {
                continue;
            }

            if ($nextTier === null || $min < $nextTier) {
                $nextTier = $min;
            }
        }

        if ($nextTier !== null) {
            return sprintf(
                /* translators: 1: quantity needed, 2: current quantity */
                __('A better price starts at %1$d items (you have %2$d), so it was not applied.', 'softminal-b2b-wholesale-for-woocommerce'),
                $nextTier,
                $qty
            );
        }

        return null;
    }

    private static function clampPrice(float $price): float
    {
        /**
         * Hard floor for any resolved B2B price. Defaults to 0 (no product is
         * ever charged negative), but a store can raise it to guard against an
         * amount-off rule larger than the price, or a below-cost misconfig,
         * charging a runaway-cheap or free line.
         *
         * @param float $floor
         */
        $floor = (float) apply_filters('smb2b_min_price', 0.0);

        return max($floor, round($price, wc_get_price_decimals()));
    }

    private static function formatPrice(float $price): string
    {
        return html_entity_decode(wp_strip_all_tags(wc_price($price)));
    }

    /**
     * WooCommerce's own active price, read with our filters disarmed.
     */
    public static function basePrice(\WC_Product $product): float
    {
        self::$readingBasePrice = true;

        try {
            $price = $product->get_price('edit');
        } finally {
            self::$readingBasePrice = false;
        }

        return (float) ($price === '' ? 0 : $price);
    }

    /**
     * Percentage and amount-off rules are calculated against the regular
     * price, never against a sale price (FEATURES A2).
     */
    public static function regularPrice(\WC_Product $product): float
    {
        self::$readingBasePrice = true;

        try {
            $price = $product->get_regular_price('edit');
        } finally {
            self::$readingBasePrice = false;
        }

        return (float) ($price === '' ? 0 : $price);
    }

    private static function memoKey(int $productId, ?int $variationId, Context $context, int $qty, int $userSalt = 0): string
    {
        return sprintf('%d:%d:%s:u%d:%d:%d', $productId, (int) $variationId, $context->cacheSegment(), $userSalt, $qty, (int) Tier::isPro());
    }

    /**
     * The per-buyer cache salt: the user id when they carry a customer-scoped
     * rule (a group-independent, per-person price), otherwise 0 so buyers who
     * share a group/company segment keep sharing one cached price. Exposed so
     * the variation-prices hash can salt Woo's own cache identically.
     */
    public static function userSalt(Context $context): int
    {
        $salt = self::hasCustomerRules($context->userId()) ? $context->userId() : 0;

        /**
         * Force a per-buyer price-cache salt. Return the user id to give this
         * buyer their own cache entries (both the resolver cache and Woo's
         * variation-prices hash), so a price that legitimately differs between
         * two buyers in the same group/company segment is never shared.
         *
         * The Dokan bridge uses this when per-vendor buyer approval is enabled: a
         * buyer approved for some vendors but not others must not share a cached
         * price with a differently-approved buyer. Default keeps the shared cache
         * (salt 0) so there is zero cost unless an add-on opts in.
         *
         * @param int     $salt    0 to share, or a positive user id to isolate.
         * @param Context $context The buyer context.
         */
        $salt = (int) apply_filters('smb2b_price_user_salt', $salt, $context);

        return max(0, $salt);
    }

    /**
     * Whether this buyer has any active customer-scoped price rule. Cached per
     * version so it costs one query per buyer and self-invalidates on any rule
     * change (which bumps the version). Guests can never have one.
     */
    private static function hasCustomerRules(int $userId): bool
    {
        if ($userId <= 0) {
            return false;
        }

        if (array_key_exists($userId, self::$customerRuleMemo)) {
            return self::$customerRuleMemo[$userId];
        }

        // Skip the per-buyer lookup entirely on the common store that uses no
        // customer-scoped rules at all: one shared store-wide check (cached per
        // version) spares every logged-in retail shopper their own COUNT query.
        if (!self::anyCustomerRules()) {
            return self::$customerRuleMemo[$userId] = false;
        }

        $cacheKey = sprintf('smb2b_has_cust:%d:%d', self::version(), $userId);
        $cached   = wp_cache_get($cacheKey, self::CACHE_GROUP);

        if (is_array($cached) && isset($cached['has'])) {
            return self::$customerRuleMemo[$userId] = (bool) $cached['has'];
        }

        global $wpdb;

        $table = Install::tableName('price_rules');

        // Table name is a trusted code-defined identifier (wpdb prefix + constant);
        // it cannot be a prepare() placeholder, and all values are prepared.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $count = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM `{$table}` WHERE active = 1 AND scope = %s AND object_id = %d",
            PriceRule::SCOPE_CUSTOMER,
            $userId
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $has = $count > 0;

        wp_cache_set($cacheKey, ['has' => $has], self::CACHE_GROUP, HOUR_IN_SECONDS);

        return self::$customerRuleMemo[$userId] = $has;
    }

    /**
     * Whether any active customer-scoped price rule exists anywhere in the
     * store. Cached per version so it costs one query for the whole site and
     * self-invalidates on any rule change. Gates the per-buyer hasCustomerRules
     * lookup so a store with no customer rules never runs it.
     */
    private static function anyCustomerRules(): bool
    {
        if (self::$anyCustomerRuleMemo !== null) {
            return self::$anyCustomerRuleMemo;
        }

        $cacheKey = sprintf('smb2b_any_cust:%d', self::version());
        $cached   = wp_cache_get($cacheKey, self::CACHE_GROUP);

        if (is_array($cached) && isset($cached['any'])) {
            return self::$anyCustomerRuleMemo = (bool) $cached['any'];
        }

        global $wpdb;

        $table = Install::tableName('price_rules');

        // Table name is a trusted code-defined identifier (wpdb prefix + constant);
        // it cannot be a prepare() placeholder, and all values are prepared.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $count = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM `{$table}` WHERE active = 1 AND scope = %s",
            PriceRule::SCOPE_CUSTOMER
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $any = $count > 0;

        wp_cache_set($cacheKey, ['any' => $any], self::CACHE_GROUP, HOUR_IN_SECONDS);

        return self::$anyCustomerRuleMemo = $any;
    }

    /**
     * The tier is part of the key because Pro unlocks whole precedence levels.
     * Without it, activating or deactivating Pro would keep serving prices
     * resolved under the previous tier until something else bumped the version.
     */
    private static function cacheKey(int $productId, ?int $variationId, Context $context, int $qty, int $userSalt = 0): string
    {
        return sprintf(
            'smb2b_price:%d:%d:%d:%d:%s:u%d:%d',
            self::version(),
            (int) Tier::isPro(),
            $productId,
            (int) $variationId,
            $context->cacheSegment(),
            $userSalt,
            $qty
        );
    }

    public static function version(): int
    {
        return max(1, (int) get_option(self::VERSION_OPTION, 1));
    }

    /**
     * Called on every write to groups, rules and assignments. Old keys are
     * orphaned rather than scanned and deleted, so invalidation is O(1)
     * regardless of catalogue size.
     */
    public static function bumpVersion(): void
    {
        update_option(self::VERSION_OPTION, self::version() + 1, false);

        self::$requestMemo         = [];
        self::$ruleSetMemo         = [];
        self::$customerRuleMemo    = [];
        self::$anyCustomerRuleMemo = null;
        Context::flush();

        // Woo caches variation price sets under its own transient-backed hash.
        if (function_exists('wc_delete_product_transients')) {
            wc_delete_product_transients();
        }
    }

    /**
     * Test/preview seam — drops per-request memoization without bumping the
     * shared version.
     */
    public static function flushRequestMemo(): void
    {
        self::$requestMemo         = [];
        self::$ruleSetMemo         = [];
        self::$customerRuleMemo    = [];
        self::$anyCustomerRuleMemo = null;
    }
}
