<?php

namespace SoftminalB2B\Cart;

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

use SoftminalB2B\Compat\Multilingual;
use SoftminalB2B\Models\BogoRule;
use SoftminalB2B\Pricing\Context;
use SoftminalB2B\Pricing\Resolver;

/**
 * Buy-X-get-Y in the cart (competitive gap feature).
 *
 * The reward is a real, separate cart line — same product, flagged as a gift —
 * priced at zero (or at a discount) when totals are calculated. Modelling it as
 * a line rather than a coupon or a fee means it works unchanged in the classic
 * cart, the block cart and the Store API: every checkout path already knows how
 * to render and total a cart item.
 *
 * The line is reconciled, not trusted: whenever the cart changes we recompute
 * how many reward units the trigger quantity earns and add, trim or drop the
 * gift line to match. A buyer cannot edit or keep a gift they no longer qualify
 * for — remove the trigger and the gift leaves with it.
 *
 * Free honours product-triggered rules whose reward is more of the same
 * product. Core's own reconcile (which product earns which gift) covers only
 * that case. Everything downstream — pricing the gift line, locking its
 * quantity, hiding its remove link, badging it, and exempting it from quantity
 * rules — keys on the {@see self::FLAG} cart-item flag alone, so it works for
 * ANY gift line regardless of which rule or product produced it. Pro therefore
 * adds category triggers and cross-product gifts by registering its own
 * reconcile on the same public cart hooks and tagging its lines with the same
 * flag; it inherits all of that behaviour without core knowing how it matched.
 */
class BogoRewards
{
    /** Cart-item flag carrying the rule id that granted a gift line. */
    public const FLAG = 'smb2b_bogo';

    private static bool $registered = false;

    /** Re-entrancy guard: adding/updating gift lines fires the same hooks. */
    private static bool $busy = false;

    /** @var array<int, array<int, BogoRule>> group id => active product rules */
    private static array $rulesMemo = [];

    /**
     * Reward price per gift-line product instance, keyed by spl_object_id.
     * Rebuilt each totals pass; read by the price override filter so it beats
     * the resolver's own get_price filter, which would otherwise re-price the
     * gift at full whack on every read.
     *
     * @var array<int, float>
     */
    private static array $giftPrices = [];

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

        self::$registered = true;

        // Keep the gift line in step with every cart change. Session load is
        // included because a rule can be created or paused while items sit in a
        // returning buyer's cart.
        add_action('woocommerce_add_to_cart', [self::class, 'reconcile'], 20);
        add_action('woocommerce_after_cart_item_quantity_update', [self::class, 'reconcile'], 20);
        add_action('woocommerce_cart_item_removed', [self::class, 'reconcile'], 20);
        add_action('woocommerce_cart_item_restored', [self::class, 'reconcile'], 20);
        add_action('woocommerce_cart_loaded_from_session', [self::class, 'reconcile'], 20);

        // Price the gift line after the resolver has set every other price
        // (PriceFilters runs at 99), so the reward is computed off the buyer's
        // real price. The map is read by the override filters below, which beat
        // the resolver's get_price filter (also 99) at priority 200 — a plain
        // set_price() alone is undone the moment anything reads the price again.
        add_action('woocommerce_before_calculate_totals', [self::class, 'setGiftPrices'], 100);
        add_filter('woocommerce_product_get_price', [self::class, 'overridePrice'], 200, 2);
        add_filter('woocommerce_product_get_sale_price', [self::class, 'overridePrice'], 200, 2);
        add_filter('woocommerce_product_variation_get_price', [self::class, 'overridePrice'], 200, 2);
        add_filter('woocommerce_product_variation_get_sale_price', [self::class, 'overridePrice'], 200, 2);

        // A gift line is not the buyer's to change, and should read as a reward
        // rather than a mistake. The badge goes through get_item_data because
        // that is the one item-annotation filter WooCommerce runs for BOTH the
        // classic cart and the Store API (block) cart. Quantity and remove are
        // locked per checkout path: the classic filters cover the classic cart;
        // the Store API editable filter covers the block cart. Even so, the real
        // guarantee is reconcile() — it runs on the Store API's own update and
        // remove hooks, so a tampered gift is corrected on the next cart change
        // whichever cart the buyer is using.
        add_filter('woocommerce_get_item_data', [self::class, 'giftItemData'], 10, 2);
        add_filter('woocommerce_cart_item_quantity', [self::class, 'lockQuantity'], 10, 3);
        add_filter('woocommerce_cart_item_remove_link', [self::class, 'hideRemoveLink'], 10, 2);
        add_filter('woocommerce_store_api_product_quantity_editable', [self::class, 'lockStoreApiQuantity'], 10, 3);

        // Nudge a buyer who is one step from a reward (classic cart).
        add_action('woocommerce_before_cart', [self::class, 'renderNudge']);
        add_action('wp_enqueue_scripts', [self::class, 'enqueueStyles']);

        // Carry the gift flag onto the order line item at checkout. Fires for
        // both the classic checkout and the Store API, so the created order
        // records which line was a promotional gift and which rule granted it.
        add_action('woocommerce_checkout_create_order_line_item', [self::class, 'stampGiftOrderItem'], 10, 4);

        // "Order again" rebuilds the cart from the order's line items but drops
        // our gift flag, so re-stamp it from the order-item meta and let
        // reconcile re-earn (or drop) the gift on the new cart.
        add_filter('woocommerce_order_again_cart_item_data', [self::class, 'restoreGiftFlagOnReorder'], 10, 3);

        add_action('smb2b_pricing_data_changed', [self::class, 'flush']);
    }

    /**
     * Persist the gift marker onto the order line item. Without it the created
     * order holds a zero-priced line with nothing identifying it as a reward, so
     * an order-editor recalculation, an order-again, or any third-party
     * re-pricing would silently charge it at full price — and there is no audit
     * trail of the promotion. Stored as hidden meta (leading underscore).
     *
     * @param mixed $item        WC_Order_Item_Product for this line.
     * @param string $cartItemKey
     * @param array<string, mixed> $values The cart item, carrying self::FLAG.
     * @param mixed $order        The order being created.
     */
    public static function stampGiftOrderItem($item, $cartItemKey, $values, $order): void
    {
        if (empty($values[self::FLAG]) || !$item instanceof \WC_Order_Item_Product) {
            return;
        }

        $item->add_meta_data('_smb2b_bogo_rule', (string) (int) $values[self::FLAG], true);
        $item->add_meta_data('_smb2b_bogo_reward', 'yes', true);
    }

    /**
     * Re-stamp the gift flag when a buyer reorders a past order.
     *
     * WooCommerce's "Order again" rebuilds the cart from the order's line items
     * but does not carry our {@see self::FLAG} across, so a former reward would
     * re-enter as a normal, full-price line — and, being unflagged, would even
     * be counted as a trigger. Recovering the flag from the order-item meta
     * hands the line back to reconcile(): a still-earned gift is re-priced as
     * the reward and trimmed to what the new cart actually earns, while a gift
     * the buyer no longer qualifies for (rule deleted, paused, or they are now
     * retail) is dropped rather than charged. All the promotion logic stays in
     * reconcile()/setGiftPrices(); this only restores the marker they key on.
     *
     * @param array<string, mixed> $data  Cart-item data being rebuilt for the line.
     * @param mixed                $item  WC_Order_Item_Product for this line.
     * @param mixed                $order The order being reordered.
     * @return array<string, mixed>
     */
    public static function restoreGiftFlagOnReorder($data, $item, $order)
    {
        if (!$item instanceof \WC_Order_Item_Product) {
            return $data;
        }

        if ($item->get_meta('_smb2b_bogo_reward') !== 'yes') {
            return $data;
        }

        $ruleId = (int) $item->get_meta('_smb2b_bogo_rule');

        if ($ruleId > 0) {
            $data[self::FLAG] = $ruleId;
        }

        return $data;
    }

    public static function flush(): void
    {
        self::$rulesMemo = [];
    }

    /**
     * Bring the gift lines into line with what the current cart earns.
     *
     * @param mixed $cart Passed by some hooks as the cart; ignored otherwise.
     */
    public static function reconcile($cart = null): void
    {
        if (self::$busy || !function_exists('WC')) {
            return;
        }

        $cart = $cart instanceof \WC_Cart ? $cart : (WC()->cart ?? null);

        if (!$cart instanceof \WC_Cart) {
            return;
        }

        $desired = self::desiredUnits($cart);

        self::$busy = true;

        try {
            $present = [];

            // Trim or drop gift lines the cart no longer earns.
            foreach ($cart->get_cart() as $key => $item) {
                if (empty($item[self::FLAG])) {
                    continue;
                }

                $ruleId = (int) $item[self::FLAG];
                $rule   = BogoRule::find($ruleId);

                // Leave Pro-owned gift lines to Pro's reconcile. Core only
                // manages lines whose rule it owns, plus orphans (rule deleted),
                // which nobody else will clean up.
                if ($rule && !$rule->isCoreOwned()) {
                    continue;
                }

                if (!isset($desired[$ruleId])) {
                    $cart->remove_cart_item($key);
                    continue;
                }

                if ((int) ($item['quantity'] ?? 0) !== $desired[$ruleId]) {
                    $cart->set_quantity($key, $desired[$ruleId], false);
                }

                $present[$ruleId] = true;
            }

            // Add gift lines the cart now earns but does not yet have.
            foreach ($desired as $ruleId => $units) {
                if (isset($present[$ruleId])) {
                    continue;
                }

                $rule = BogoRule::find($ruleId);

                if (!$rule) {
                    continue;
                }

                $giftProduct = wc_get_product((int) $rule->buy_id);

                // The core rule gifts more of the trigger product itself. Skip
                // quietly when it cannot actually be added: a variable product
                // (converted after the rule was made — creation blocks it) has no
                // addable variation, and an unpurchasable or out-of-stock product
                // cannot be gifted. Attempting the add would fail and, worse,
                // WooCommerce would show a stray error to a buyer who never asked
                // for the gift.
                if (!$giftProduct
                    || $giftProduct->is_type('variable')
                    || !$giftProduct->is_purchasable()
                    || !$giftProduct->is_in_stock()
                    || ($giftProduct->managing_stock() && !$giftProduct->has_enough_stock($units))
                ) {
                    continue;
                }

                // add_to_cart runs its own validation and can enqueue notices, so
                // snapshot the notice queue and restore it afterwards — a
                // background reconcile must never speak to the shopper.
                $notices = function_exists('wc_get_notices') ? wc_get_notices() : null;

                $cart->add_to_cart((int) $rule->buy_id, $units, 0, [], [self::FLAG => $ruleId]);

                if ($notices !== null && function_exists('wc_set_notices')) {
                    wc_set_notices($notices);
                }
            }
        } finally {
            self::$busy = false;
        }
    }

    /**
     * Trigger totals per product for a cart, keyed by the default-language
     * product id and counting only what the buyer actually bought — a gift line
     * never counts toward a trigger. Shared by the reconcile, the gift pricing
     * and the nudge so all three read the same "what did they buy" figure.
     *
     * @return array<int, int>
     */
    private static function triggerQuantities(\WC_Cart $cart): array
    {
        $triggerQty = [];

        foreach ($cart->get_cart() as $item) {
            if (!empty($item[self::FLAG])) {
                continue;
            }

            // Fold translations onto the default-language id so a rule on the
            // original product is triggered by any translation of it.
            $productId = Multilingual::productId((int) ($item['product_id'] ?? 0));
            $triggerQty[$productId] = ($triggerQty[$productId] ?? 0) + (int) ($item['quantity'] ?? 0);
        }

        return $triggerQty;
    }

    /**
     * Reward units owed per rule for a cart, keyed by rule id (only entries > 0).
     *
     * @return array<int, int>
     */
    private static function desiredUnits(\WC_Cart $cart): array
    {
        if (Context::forUser()->isRetail()) {
            return [];
        }

        $rules = self::rulesForBuyer();

        if (!$rules) {
            return [];
        }

        // Trigger totals per product, counting only what the buyer actually
        // bought — a gift line never earns further gifts.
        $triggerQty = self::triggerQuantities($cart);

        $desired = [];

        foreach ($rules as $rule) {
            $units = $rule->rewardUnitsFor($triggerQty[(int) $rule->buy_id] ?? 0);

            if ($units > 0) {
                $desired[(int) $rule->id] = $units;
            }
        }

        return $desired;
    }

    /**
     * Price every gift line: free, or the configured discount off the buyer's
     * resolved unit price.
     *
     * @param mixed $cart
     */
    public static function setGiftPrices($cart): void
    {
        if (!$cart instanceof \WC_Cart) {
            return;
        }

        self::$giftPrices = [];

        // The reward's percentage/amount is taken off the buyer's *effective*
        // unit price — the quantity tier the trigger purchase actually reached,
        // not the qty-1 catalogue price — so a "buy 10, get 2 at 50% off" deal
        // discounts the 10+ tier price the buyer is really paying.
        $triggerQty = self::triggerQuantities($cart);

        foreach ($cart->get_cart() as $item) {
            if (empty($item[self::FLAG]) || empty($item['data']) || !$item['data'] instanceof \WC_Product) {
                continue;
            }

            $product = $item['data'];
            $rule    = BogoRule::find((int) $item[self::FLAG]);

            if (!$rule) {
                $reward = 0.0;
            } else {
                $productId   = (int) ($item['product_id'] ?? $product->get_id());
                $variationId = (int) ($item['variation_id'] ?? 0);

                // Price the reward at the quantity the buyer bought of the
                // trigger product (folded to the default language, as the map
                // is keyed), falling back to a single unit.
                $triggerKey  = Multilingual::productId((int) $rule->buy_id);
                $qtyForPrice = max(1, (int) ($triggerQty[$triggerKey] ?? 1));
                $base        = (float) Resolver::resolve($productId, $variationId ?: null, null, $qtyForPrice)->price();
                $reward      = $rule->rewardPriceFor($base);
            }

            self::$giftPrices[spl_object_id($product)] = $reward;
            $product->set_price((string) $reward);
        }
    }

    /**
     * Force the reward price for a known gift-line product instance, whatever
     * the resolver would otherwise return. Instance-keyed so it only ever
     * touches the specific line the promotion added, never the buyer's own copy
     * of the same product.
     *
     * @param string|float $price
     * @param \WC_Product  $product
     * @return string|float
     */
    public static function overridePrice($price, $product)
    {
        if ($product instanceof \WC_Product && isset(self::$giftPrices[spl_object_id($product)])) {
            return (string) self::$giftPrices[spl_object_id($product)];
        }

        return $price;
    }

    /**
     * Replace the quantity input with plain, uneditable text for gift lines.
     *
     * @param string $html
     * @param string $cartItemKey
     * @param array<string, mixed> $cartItem
     * @return string
     */
    public static function lockQuantity($html, $cartItemKey, $cartItem)
    {
        if (empty($cartItem[self::FLAG])) {
            return $html;
        }

        return sprintf(
            '<span class="smb2b-bogo-qty">%s</span>',
            esc_html((string) ($cartItem['quantity'] ?? 0))
        );
    }

    /**
     * Suppress the remove link on gift lines — the reward comes and goes with
     * the trigger, not by the buyer's hand.
     *
     * @param string $link
     * @param string $cartItemKey
     * @return string
     */
    public static function hideRemoveLink($link, $cartItemKey)
    {
        $cart = function_exists('WC') ? WC()->cart : null;
        $item = $cart ? $cart->get_cart_item($cartItemKey) : null;

        if ($item && !empty($item[self::FLAG])) {
            return '';
        }

        return $link;
    }

    /**
     * Annotate the gift line so a shopper reads it as a reward, in whichever
     * cart they are using. The value names the kind of reward so a discounted
     * gift is not mistaken for a full-price line the buyer forgot about.
     *
     * @param array<int, array<string, mixed>> $itemData
     * @param array<string, mixed>             $cartItem
     * @return array<int, array<string, mixed>>
     */
    public static function giftItemData($itemData, $cartItem)
    {
        if (empty($cartItem[self::FLAG])) {
            return $itemData;
        }

        $rule  = BogoRule::find((int) $cartItem[self::FLAG]);
        $value = __('Free gift', 'softminal-b2b-wholesale-for-woocommerce');

        if ($rule && $rule->reward_type === BogoRule::REWARD_PERCENT) {
            $value = sprintf(
                /* translators: %s: percentage off */
                __('Gift — %s%% off', 'softminal-b2b-wholesale-for-woocommerce'),
                rtrim(rtrim((string) (float) $rule->reward_value, '0'), '.')
            );
        } elseif ($rule && $rule->reward_type === BogoRule::REWARD_AMOUNT) {
            $value = __('Discounted gift', 'softminal-b2b-wholesale-for-woocommerce');
        }

        $itemData[] = [
            'key'   => __('Reward', 'softminal-b2b-wholesale-for-woocommerce'),
            'value' => $value,
        ];

        return $itemData;
    }

    /**
     * Make the block cart render a gift line's quantity as fixed, not a stepper.
     *
     * @param bool                 $editable
     * @param \WC_Product          $product
     * @param array<string, mixed> $cartItem
     * @return bool
     */
    public static function lockStoreApiQuantity($editable, $product, $cartItem)
    {
        if (is_array($cartItem) && !empty($cartItem[self::FLAG])) {
            return false;
        }

        return $editable;
    }

    /**
     * A "you're almost there" line for each rule the cart partly triggers.
     */
    public static function renderNudge(): void
    {
        if (!function_exists('WC') || !WC()->cart || Context::forUser()->isRetail()) {
            return;
        }

        $rules = self::rulesForBuyer();

        if (!$rules) {
            return;
        }

        $triggerQty = self::triggerQuantities(WC()->cart);

        foreach ($rules as $rule) {
            $have = $triggerQty[(int) $rule->buy_id] ?? 0;
            $need = max(1, (int) $rule->buy_qty);

            // Only nudge when they have started but not yet reached the trigger.
            if ($have <= 0 || $have >= $need) {
                continue;
            }

            $product = wc_get_product((int) $rule->buy_id);

            if (!$product) {
                continue;
            }

            $short = $need - $have;

            $message = sprintf(
                /* translators: 1: how many more to add, 2: product name, 3: reward units */
                _n(
                    'Add %1$d more %2$s to get %3$d free.',
                    'Add %1$d more %2$s to get %3$d free.',
                    $short,
                    'softminal-b2b-wholesale-for-woocommerce'
                ),
                $short,
                $product->get_name(),
                max(1, (int) $rule->reward_qty)
            );

            echo '<div class="smb2b-bogo-nudge woocommerce-info">' . esc_html($message) . '</div>';
        }
    }

    public static function enqueueStyles(): void
    {
        if (!function_exists('is_cart') || !(is_cart() || is_checkout())) {
            return;
        }

        $css = ''
            . '.smb2b-bogo-qty{display:inline-block;font-weight:600;opacity:.8}'
            . '.smb2b-bogo-nudge{margin-bottom:12px}';

        wp_register_style('smb2b-bogo', false, [], SMB2B_VERSION);
        wp_enqueue_style('smb2b-bogo');
        wp_add_inline_style('smb2b-bogo', $css);
    }

    /**
     * Active product-trigger rules that apply to the current buyer — a rule for
     * their group, or one for everyone. Memoised per group for the request.
     *
     * @return array<int, BogoRule>
     */
    private static function rulesForBuyer(): array
    {
        $groupId = (int) Context::forUser()->groupId();

        if (isset(self::$rulesMemo[$groupId])) {
            return self::$rulesMemo[$groupId];
        }

        // Core owns exactly the product-trigger, same-product-reward rule.
        // Category triggers and cross-product/cheapest rewards are Pro's, and
        // its module reconciles those on the same hooks.
        $rules = BogoRule::query()
            ->where('active', 1)
            ->where('buy_type', BogoRule::BUY_PRODUCT)
            ->where('reward_scope', BogoRule::REWARD_SCOPE_SAME)
            ->get();

        // Keep only the rules that apply to this buyer (their group, or the
        // everyone rule).
        $applicable = [];

        foreach ($rules as $rule) {
            $ruleGroup = $rule->group_id === null ? null : (int) $rule->group_id;

            if ($ruleGroup === null || $ruleGroup === $groupId) {
                $applicable[] = $rule;
            }
        }

        // Exactly one rule wins per trigger product. Stacking every overlapping
        // match would mint a gift line each and over-gift at the store's
        // expense, so we rank and keep a single winner: a group-specific rule
        // beats an everyone rule, then the higher `priority` wins, then the
        // oldest rule. This makes `priority` the merchant's lever for deciding
        // which of two competing promos on the same product applies.
        usort($applicable, static function (BogoRule $a, BogoRule $b) {
            $aSpecific = $a->group_id === null ? 0 : 1;
            $bSpecific = $b->group_id === null ? 0 : 1;

            if ($aSpecific !== $bSpecific) {
                return $bSpecific <=> $aSpecific;
            }

            if ((int) $a->priority !== (int) $b->priority) {
                return (int) $b->priority <=> (int) $a->priority;
            }

            return (int) $a->id <=> (int) $b->id;
        });

        $winners = [];

        foreach ($applicable as $rule) {
            $buyId = (int) $rule->buy_id;

            if (!isset($winners[$buyId])) {
                $winners[$buyId] = $rule;
            }
        }

        return self::$rulesMemo[$groupId] = array_values($winners);
    }
}
