<?php

namespace SoftminalB2B\Pricing;

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

/**
 * The one and only place WooCommerce prices are hooked.
 *
 * ARCHITECTURE §3 forbids scattering price mutations across many hooks — that
 * is the documented B2BKing filter-conflict failure. Everything here delegates
 * to Resolver; no pricing decisions are made in this class.
 *
 * Classic and Blocks/Store API both read prices through the same product CRUD
 * and the same cart totals, so hooking these points keeps them in lockstep by
 * construction rather than by duplicated logic.
 */
class PriceFilters
{
    private static bool $registered = false;

    /**
     * Cart line quantities, keyed by the spl_object_id of the cart item's
     * product instance. Cart items get their own product objects, so this
     * cannot collide with catalogue rendering.
     *
     * @var array<int, int>
     */
    private static array $cartQuantities = [];

    /** Late enough to sit above other pricing plugins, early enough to be overridable. */
    private const PRIORITY = 99;

    public static function init(): void
    {
        if (self::$registered) {
            return;
        }

        self::$registered = true;

        // Catalogue + single product, simple and variable.
        add_filter('woocommerce_product_get_price', [self::class, 'filterPrice'], self::PRIORITY, 2);
        add_filter('woocommerce_product_get_sale_price', [self::class, 'filterSalePrice'], self::PRIORITY, 2);
        add_filter('woocommerce_product_variation_get_price', [self::class, 'filterPrice'], self::PRIORITY, 2);
        add_filter('woocommerce_product_variation_get_sale_price', [self::class, 'filterSalePrice'], self::PRIORITY, 2);

        // Variable-product price ranges ("From $X").
        add_filter('woocommerce_variation_prices_price', [self::class, 'filterVariationPrice'], self::PRIORITY, 3);
        add_filter('woocommerce_variation_prices_sale_price', [self::class, 'filterVariationSalePrice'], self::PRIORITY, 3);

        // Woo caches the whole variation price set under this hash. Without the
        // buyer's segment in it, a 300-variation product would serve one group's
        // prices to another — and recompute per request instead of per group.
        add_filter('woocommerce_get_variation_prices_hash', [self::class, 'filterVariationPricesHash'], self::PRIORITY, 3);

        // Cart/checkout, where the real quantity finally exists. Store API and
        // classic both route through this.
        add_action('woocommerce_before_calculate_totals', [self::class, 'applyCartPrices'], self::PRIORITY, 1);

        // Coupons are applied after resolution, never inside it (FEATURES A6).
        add_filter('woocommerce_coupon_is_valid', [self::class, 'filterCouponIsValid'], self::PRIORITY, 2);

        self::registerInvalidationHooks();
    }

    /**
     * Any change to who is in which group must invalidate resolved prices, or
     * the merchant sees the "assignment didn't take" staleness bug (A1).
     */
    private static function registerInvalidationHooks(): void
    {
        $bump = [Resolver::class, 'bumpVersion'];

        add_action('updated_user_meta', static function ($metaId, $userId, $metaKey) use ($bump) {
            if ($metaKey === \SoftminalB2B\Models\Group::USER_META_KEY) {
                call_user_func($bump);
            }
        }, 10, 3);

        add_action('added_user_meta', static function ($metaId, $userId, $metaKey) use ($bump) {
            if ($metaKey === \SoftminalB2B\Models\Group::USER_META_KEY) {
                call_user_func($bump);
            }
        }, 10, 3);

        add_action('deleted_user_meta', static function ($metaIds, $userId, $metaKey) use ($bump) {
            if ($metaKey === \SoftminalB2B\Models\Group::USER_META_KEY) {
                call_user_func($bump);
            }
        }, 10, 3);

        // A product's own price is an input to every percent/amount-off B2B
        // price, so editing it must invalidate the resolved-price cache — the
        // "I changed it and nothing happened" staleness bug (A1). Hooking the
        // price meta keys (rather than woocommerce_update_product) fires only
        // when a price actually changes, not on stock-only saves, and covers the
        // scheduled-sale case where _price flips without a manual edit.
        $priceMetaKeys = ['_price', '_regular_price', '_sale_price'];

        $onPriceMeta = static function ($metaId, $objectId, $metaKey) use ($bump, $priceMetaKeys) {
            if (in_array($metaKey, $priceMetaKeys, true)) {
                call_user_func($bump);
            }
        };

        add_action('added_post_meta', $onPriceMeta, 10, 3);
        add_action('updated_post_meta', $onPriceMeta, 10, 3);
        add_action('deleted_post_meta', $onPriceMeta, 10, 3);

        add_action('smb2b_pricing_data_changed', $bump);
    }

    /**
     * True when we must not touch the price: admin screens (order editing shows
     * what was actually charged), and re-entrant reads of Woo's own price.
     */
    private static function shouldSkip(): bool
    {
        if (Resolver::isReadingBasePrice()) {
            return true;
        }

        if (is_admin() && !wp_doing_ajax()) {
            return true;
        }

        return (bool) apply_filters('smb2b_skip_price_filters', false);
    }

    /**
     * @param string|float $price
     * @return string|float
     */
    public static function filterPrice($price, $product)
    {
        if (self::shouldSkip() || !$product instanceof \WC_Product) {
            return $price;
        }

        $resolved = self::resolveFor($product);

        if ($resolved === null || !$resolved->applies()) {
            return $price;
        }

        return (string) $resolved->price();
    }

    /**
     * Mirroring the resolved price into the sale price is what makes Woo render
     * the regular price struck through, so B2B buyers see what they saved.
     *
     * Gated on applies() (a rule matched), not isDiscounted() (below the active
     * price): a B2B price between a storewide sale price and the regular price
     * must be both charged and shown, or the displayed sale price would disagree
     * with what get_price() charges. When the B2B price sits at or above the
     * regular price Woo's own is_on_sale() guard keeps it from rendering as a
     * fake discount.
     *
     * @param string|float $salePrice
     * @return string|float
     */
    public static function filterSalePrice($salePrice, $product)
    {
        if (self::shouldSkip() || !$product instanceof \WC_Product) {
            return $salePrice;
        }

        $resolved = self::resolveFor($product);

        if ($resolved === null || !$resolved->applies()) {
            return $salePrice;
        }

        return (string) $resolved->price();
    }

    /**
     * @param string|float $price
     * @return string|float
     */
    public static function filterVariationPrice($price, $variation, $product)
    {
        if (self::shouldSkip() || !$variation instanceof \WC_Product) {
            return $price;
        }

        $resolved = self::resolveFor($variation);

        if ($resolved === null || !$resolved->applies()) {
            return $price;
        }

        return (string) $resolved->price();
    }

    /**
     * @param string|float $price
     * @return string|float
     */
    public static function filterVariationSalePrice($price, $variation, $product)
    {
        return self::filterVariationPrice($price, $variation, $product);
    }

    /**
     * @param array<int|string, mixed> $hash
     * @return array<int|string, mixed>
     */
    public static function filterVariationPricesHash($hash, $product, $forDisplay)
    {
        if (!is_array($hash)) {
            return $hash;
        }

        $context = Context::forUser();

        // Match the resolver's per-buyer salt: a customer-scoped rule can change
        // a variation's price, so its owner must not share Woo's cached price set
        // with other buyers in the same group/company segment.
        $hash['smb2b'] = $context->cacheSegment() . ':u' . Resolver::userSalt($context) . ':v' . Resolver::version();

        return $hash;
    }

    /**
     * Cart lines are the only place the real quantity is known, so quantity
     * bands are applied here rather than in the catalogue filters.
     */
    public static function applyCartPrices($cart): void
    {
        if (self::shouldSkip() || !$cart instanceof \WC_Cart) {
            return;
        }

        // No did_action() guard here: a cart legitimately recalculates several
        // times per request (add to cart, apply coupon, recalculate shipping),
        // and skipping the later passes would drop quantity-tier pricing.
        // Resolution is memoised, so repeat calls are cheap.
        foreach ($cart->get_cart() as $cartItem) {
            if (empty($cartItem['data']) || !$cartItem['data'] instanceof \WC_Product) {
                continue;
            }

            $product     = $cartItem['data'];
            $qty         = (int) ($cartItem['quantity'] ?? 1);
            $productId   = (int) ($cartItem['product_id'] ?? $product->get_id());
            $variationId = (int) ($cartItem['variation_id'] ?? 0);

            // Bind the line quantity to this product instance so the price
            // filters below resolve the correct tier. Without this they would
            // re-resolve at quantity 1 and undo the tier price.
            self::$cartQuantities[spl_object_id($product)] = $qty;

            $resolved = Resolver::resolve($productId, $variationId ?: null, null, $qty);

            if ($resolved->applies()) {
                $product->set_price((string) $resolved->price());
            }
        }
    }

    /**
     * Per-group "disallow coupons" toggle (FEATURES A6). This is a gate on
     * whether a coupon may be used at all — it never feeds back into pricing.
     *
     * @param bool $valid
     * @return bool
     */
    public static function filterCouponIsValid($valid, $coupon)
    {
        if (!$valid) {
            return $valid;
        }

        $context = Context::forUser();

        if (!$context->isRetail() && !$context->allowsCoupons()) {
            throw new \Exception(
                esc_html__('Coupons are not available on your wholesale account, because your account already has B2B pricing.', 'softminal-b2b-wholesale-for-woocommerce')
            );
        }

        return $valid;
    }

    /**
     * Catalogue rendering resolves at quantity 1; a product instance that
     * belongs to a cart line resolves at that line's quantity instead.
     */
    private static function resolveFor(\WC_Product $product): ?ResolvedPrice
    {
        $productId   = $product->get_id();
        $parentId    = $product->get_parent_id();
        $variationId = null;

        if ($parentId) {
            $variationId = $productId;
            $productId   = $parentId;
        }

        if (!$productId) {
            return null;
        }

        $qty = self::$cartQuantities[spl_object_id($product)] ?? 1;

        return Resolver::resolve((int) $productId, $variationId, null, $qty);
    }
}
