<?php

namespace SoftminalB2B\Cart;

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

use SoftminalB2B\Compat\Multilingual;
use SoftminalB2B\Install;
use SoftminalB2B\Models\QuantityRule;
use SoftminalB2B\Pricing\Context;
use SoftminalB2B\Storefront\PriceVisibility;

/**
 * Minimum, maximum and step quantities (FEATURES A5).
 *
 * Enforced twice on purpose: once when the item is added, and again when the
 * cart is checked. A cart can be filled before a buyer logs in, or a rule can
 * change while items sit in a cart, so add-to-cart validation alone would let
 * invalid orders reach checkout.
 *
 * Classic and Blocks are both covered — Blocks validates through the Store API,
 * which raises errors from its own hook rather than from wc_add_notice.
 */
class QuantityRules
{
    private static bool $registered = false;

    /** @var array<string, QuantityRule|null> */
    private static array $memo = [];

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

        self::$registered = true;

        // Show the constraint in the quantity box before anyone gets it wrong.
        add_filter('woocommerce_quantity_input_args', [self::class, 'filterQuantityArgs'], 10, 2);
        add_filter('woocommerce_available_variation', [self::class, 'filterVariationArgs'], 10, 3);

        // Surface the constraint where the buyer is deciding: a line by the
        // quantity box on the product page, and a small badge on shop cards —
        // only for products that actually carry a rule for this viewer.
        // The card badge is attached to the price HTML rather than a loop hook:
        // themes reorder loop hooks freely (Astra fires the title hook inside
        // the thumbnail), but the price element is always rendered in one
        // predictable place, so the badge lands right beneath it every time.
        add_action('woocommerce_before_add_to_cart_quantity', [self::class, 'renderProductNotice']);
        add_filter('woocommerce_get_price_html', [self::class, 'appendBadgeToPrice'], 99, 2);

        // Stylesheet for the notice/badge, printed once in the head so it never
        // lands in the middle of a card's markup.
        add_action('wp_enqueue_scripts', [self::class, 'enqueueStyles']);

        // A shop-card "Add to cart" adds one by default, which for a minimum-10
        // product would just bounce off the validation below. Add the minimum
        // instead so the click does the sensible thing.
        add_filter('woocommerce_loop_add_to_cart_args', [self::class, 'loopAddToCartArgs'], 10, 2);
        add_filter('woocommerce_loop_add_to_cart_link', [self::class, 'loopAddToCartLink'], 10, 3);

        // Classic add-to-cart. Note this filter is applied by WooCommerce's
        // form handler and session restore, NOT by WC_Cart::add_to_cart(), so
        // it does not cover the Blocks path or programmatic adds.
        add_filter('woocommerce_add_to_cart_validation', [self::class, 'validateAddToCart'], 10, 5);
        add_filter('woocommerce_update_cart_validation', [self::class, 'validateCartUpdate'], 10, 4);
        add_action('woocommerce_check_cart_items', [self::class, 'validateCartItems']);

        // Blocks / Store API. Adding and updating go through their own
        // validation actions, and cart errors surface via a WP_Error rather
        // than the notice queue.
        add_action('woocommerce_store_api_validate_add_to_cart', [self::class, 'validateStoreApiAdd'], 10, 2);
        add_action('woocommerce_store_api_validate_cart_item', [self::class, 'validateStoreApiItem'], 10, 2);
        add_action('woocommerce_store_api_cart_errors', [self::class, 'validateStoreApiCart'], 10, 2);

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

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

    /**
     * The rule that applies to one product for the current buyer, or null.
     *
     * A rule targeted at the buyer's group beats a rule that applies to
     * everyone, so a merchant can set a general pack size and then relax it for
     * wholesale.
     */
    public static function forProduct(int $productId, ?int $variationId = null, ?int $userId = null): ?QuantityRule
    {
        $context = Context::forUser($userId);
        $groupId = $context->groupId();

        $key = sprintf('%d:%d:%d', $productId, (int) $variationId, (int) $groupId);

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

        global $wpdb;

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

        // Match on the default-language ids so a rule set on the original
        // product applies to its translations too.
        $objectIds = Multilingual::productIds(array_filter([$productId, $variationId]));

        if (empty($objectIds)) {
            return self::$memo[$key] = null;
        }

        $placeholders = implode(', ', array_fill(0, count($objectIds), '%d'));

        // Pick the winner in SQL — a rule aimed at the buyer's group beats the
        // everyone rule, and a variation-level rule beats its parent's. Only
        // the id is selected; the model layer hydrates and caches the row.
        $sql = "SELECT id FROM `{$table}`
                WHERE active = 1
                  AND scope = %s
                  AND object_id IN ({$placeholders})
                  AND (group_id = %d OR group_id IS NULL)
                ORDER BY (group_id IS NULL) ASC, object_id DESC
                LIMIT 1";

        $params = array_merge([QuantityRule::SCOPE_PRODUCT], $objectIds, [(int) $groupId]);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $ruleId = (int) $wpdb->get_var($wpdb->prepare($sql, $params));
        $rule   = $ruleId ? QuantityRule::find($ruleId) : null;

        /**
         * Supply a quantity rule for a product from a broader scope when no
         * product-specific rule matched. Pro fills this with category rules
         * (precedence: product beats category), so core never has to know how
         * to resolve a category — it only owns the product scope.
         *
         * @param QuantityRule|null $rule        Product-scope match, or null.
         * @param int               $productId
         * @param int|null          $variationId
         * @param int|null          $groupId     Buyer's group, or null.
         * @param int|null          $userId
         */
        $rule = apply_filters('smb2b_quantity_rule_for_product', $rule, $productId, $variationId, $groupId, $userId);

        return self::$memo[$key] = ($rule instanceof QuantityRule) ? $rule : null;
    }

    /**
     * @param array<string, mixed> $args
     * @return array<string, mixed>
     */
    public static function filterQuantityArgs($args, $product)
    {
        if (!$product instanceof \WC_Product) {
            return $args;
        }

        $rule = self::ruleForProductObject($product);

        if (!$rule) {
            return $args;
        }

        if ($rule->min_qty !== null) {
            $args['min_value'] = (int) $rule->min_qty;
            // Woo pre-fills the input from min_value; make the default valid.
            if (isset($args['input_value']) && (int) $args['input_value'] < (int) $rule->min_qty) {
                $args['input_value'] = (int) $rule->min_qty;
            }
        }

        if ($rule->max_qty !== null) {
            $args['max_value'] = (int) $rule->max_qty;
        }

        if ($rule->step_qty !== null && (int) $rule->step_qty > 1) {
            $args['step'] = (int) $rule->step_qty;
        }

        return $args;
    }

    // ------------------------------------------------------------------ display

    /**
     * A line beside the quantity box on the single product page, explaining the
     * minimum/pack the input is already enforcing. Only shown when there is a
     * real constraint for this viewer.
     */
    public static function renderProductNotice(): void
    {
        global $product;

        if (!$product instanceof \WC_Product) {
            return;
        }

        $rule = self::ruleForProductObject($product);

        if (!$rule || !self::hasConstraint($rule)) {
            return;
        }

        printf(
            '<p class="smb2b-moq-notice">%s</p>',
            esc_html(self::describeRule($rule, false))
        );
    }

    /**
     * A compact badge beneath the price on a shop-loop card. Attached to the
     * price HTML so it lands in one predictable place across themes; skipped on
     * the single product page (which shows the fuller notice) and anywhere off
     * a product archive.
     *
     * @param string $price
     * @return string
     */
    public static function appendBadgeToPrice($price, $product)
    {
        if (!$product instanceof \WC_Product || !function_exists('is_shop')) {
            return $price;
        }

        // Product cards live on the shop, category/tag archives and search. The
        // single product page uses renderProductNotice instead, and admin/cart
        // price renders are left alone.
        if (is_admin() || is_product() || !(is_shop() || is_product_taxonomy() || is_search())) {
            return $price;
        }

        // The MOQ is commercial info like the price itself. When the price is
        // hidden from this visitor (a guest on a private/hide-prices store), the
        // badge follows it — otherwise "Log in to see prices" would sit next to
        // a stray "MOQ: 12". Logged-in customers, who can see the price, see it.
        if (class_exists(PriceVisibility::class) && PriceVisibility::isHidden()) {
            return $price;
        }

        $rule = self::ruleForProductObject($product);

        if (!$rule || !self::hasConstraint($rule)) {
            return $price;
        }

        return $price . sprintf(
            '<span class="smb2b-moq-badge">%s</span>',
            esc_html(self::describeRule($rule, true))
        );
    }

    /**
     * Make a shop-card add-to-cart add the minimum, so the click succeeds
     * instead of bouncing off validateAddToCart. Covers the AJAX path via
     * data-quantity.
     *
     * @param array<string, mixed> $args
     * @return array<string, mixed>
     */
    public static function loopAddToCartArgs($args, $product)
    {
        $min = self::loopMinimumFor($product);

        if ($min > 1) {
            $args['quantity'] = $min;
        }

        return $args;
    }

    /**
     * The non-AJAX fallback: append the quantity to the add-to-cart URL so a
     * plain link add also gets the minimum, not one.
     *
     * @param string $html
     * @return string
     */
    public static function loopAddToCartLink($html, $product, $args = [])
    {
        $min = self::loopMinimumFor($product);

        if ($min > 1 && $product instanceof \WC_Product) {
            $needle = 'add-to-cart=' . $product->get_id();

            // Guard on the URL param specifically — checking for a bare
            // "quantity=" would match the data-quantity attribute and wrongly
            // skip appending to the href.
            $alreadyInUrl = strpos($html, $needle . '&amp;quantity=') !== false
                || strpos($html, $needle . '&quantity=') !== false;

            if (strpos($html, $needle) !== false && !$alreadyInUrl) {
                $html = str_replace($needle, $needle . '&amp;quantity=' . $min, $html);
            }
        }

        return $html;
    }

    private static function loopMinimumFor($product): int
    {
        if (!$product instanceof \WC_Product) {
            return 1;
        }

        $rule = self::ruleForProductObject($product);

        return ($rule && $rule->min_qty !== null) ? max(1, (int) $rule->min_qty) : 1;
    }

    /**
     * Whether a rule is worth surfacing: a plain "minimum 1, no pack, no cap" is
     * not a constraint anyone needs told about.
     */
    private static function hasConstraint(QuantityRule $rule): bool
    {
        $min  = $rule->min_qty === null ? 1 : (int) $rule->min_qty;
        $max  = $rule->max_qty === null ? null : (int) $rule->max_qty;
        $step = $rule->step_qty === null ? 1 : (int) $rule->step_qty;

        return $min > 1 || $step > 1 || $max !== null;
    }

    /**
     * A plain-language label for the rule. $short is the compact card badge;
     * long form is the product-page line.
     */
    private static function describeRule(QuantityRule $rule, bool $short): string
    {
        $min  = $rule->min_qty === null ? null : (int) $rule->min_qty;
        $max  = $rule->max_qty === null ? null : (int) $rule->max_qty;
        $step = $rule->step_qty === null ? null : (int) $rule->step_qty;

        $hasMin  = $min !== null && $min > 1;
        $hasStep = $step !== null && $step > 1;

        // The badge leads with the minimum order quantity — the number a buyer
        // most needs — falling back to pack size or a max when there is no
        // minimum above one.
        if ($short) {
            if ($hasMin) {
                /* translators: %d: minimum order quantity */
                return sprintf(__('MOQ: %d', 'softminal-b2b-wholesale-for-woocommerce'), $min);
            }
            if ($hasStep) {
                /* translators: %d: pack size */
                return sprintf(__('Packs of %d', 'softminal-b2b-wholesale-for-woocommerce'), $step);
            }
            if ($max !== null) {
                /* translators: %d: maximum quantity */
                return sprintf(__('Max. %d', 'softminal-b2b-wholesale-for-woocommerce'), $max);
            }
            return '';
        }

        // Product-page notice: spell the minimum out, then the pack (only when
        // it differs from the minimum) and any cap.
        if ($hasMin) {
            /* translators: %d: minimum order quantity */
            $text = sprintf(__('Minimum order: %d units', 'softminal-b2b-wholesale-for-woocommerce'), $min);

            if ($hasStep && $step !== $min) {
                /* translators: %d: pack size */
                $text .= ' ' . sprintf(__('(in packs of %d)', 'softminal-b2b-wholesale-for-woocommerce'), $step);
            }

            if ($max !== null) {
                /* translators: %d: maximum quantity */
                $text .= ', ' . sprintf(__('up to %d', 'softminal-b2b-wholesale-for-woocommerce'), $max);
            }

            return $text . '.';
        }

        if ($hasStep) {
            /* translators: %d: pack size */
            $text = sprintf(__('Sold in packs of %d', 'softminal-b2b-wholesale-for-woocommerce'), $step);

            if ($max !== null) {
                /* translators: %d: maximum quantity */
                $text .= ', ' . sprintf(__('up to %d', 'softminal-b2b-wholesale-for-woocommerce'), $max);
            }

            return $text . '.';
        }

        if ($max !== null) {
            /* translators: %d: maximum quantity */
            return sprintf(__('Maximum %d per order.', 'softminal-b2b-wholesale-for-woocommerce'), $max);
        }

        return '';
    }

    /**
     * Minimal, theme-agnostic styling for the notice and badge. Printed in the
     * head on storefront pages only. The badge is block-level so it sits on its
     * own line beneath the price on a card, rather than crowding it.
     */
    public static function enqueueStyles(): void
    {
        if (!function_exists('is_woocommerce') || !(is_woocommerce() || is_shop())) {
            return;
        }

        $css = ''
            . '.smb2b-moq-badge{display:block;width:fit-content;width:-moz-fit-content;margin:6px 0 2px;'
            . 'padding:2px 9px;border-radius:999px;background:rgba(79,70,229,.08);color:#4f46e5;'
            . 'font-size:11px;font-weight:600;line-height:1.7;letter-spacing:.02em}'
            . '.smb2b-moq-notice{margin:0 0 10px;padding:6px 10px;border-radius:8px;'
            . 'background:rgba(79,70,229,.06);color:#4338ca;font-size:13px;font-weight:500}';

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

    /**
     * @param array<string, mixed> $data
     * @return array<string, mixed>
     */
    public static function filterVariationArgs($data, $parent, $variation)
    {
        if (!$variation instanceof \WC_Product) {
            return $data;
        }

        $rule = self::ruleForProductObject($variation);

        if (!$rule) {
            return $data;
        }

        if ($rule->min_qty !== null) {
            $data['min_qty'] = (int) $rule->min_qty;
        }

        if ($rule->max_qty !== null) {
            $data['max_qty'] = (int) $rule->max_qty;
        }

        return $data;
    }

    /**
     * @param bool $passed
     * @return bool
     */
    public static function validateAddToCart($passed, $productId, $quantity, $variationId = 0, $variations = [])
    {
        if (!$passed) {
            return $passed;
        }

        $rule = self::forProduct((int) $productId, $variationId ? (int) $variationId : null);

        if (!$rule) {
            return $passed;
        }

        // Adding to what is already in the cart must be judged on the total the
        // rule governs — the whole product for a parent-scoped rule, so a max
        // can't be beaten by spreading units across variations.
        $existing  = self::cartQuantityForRule($rule, (int) $productId, (int) $variationId);
        $total     = $existing + (int) $quantity;
        $violation = $rule->violationFor($total, self::productName((int) $productId, (int) $variationId));

        if ($violation !== null) {
            wc_add_notice($violation, 'error');
            return false;
        }

        return $passed;
    }

    /**
     * @param bool $passed
     * @return bool
     */
    public static function validateCartUpdate($passed, $cartItemKey, $values, $quantity)
    {
        if (!$passed) {
            return $passed;
        }

        $productId   = (int) ($values['product_id'] ?? 0);
        $variationId = (int) ($values['variation_id'] ?? 0);

        $rule = self::forProduct($productId, $variationId ?: null);

        if (!$rule) {
            return $passed;
        }

        $violation = $rule->violationFor((int) $quantity, self::productName($productId, $variationId));

        if ($violation !== null) {
            wc_add_notice($violation, 'error');
            return false;
        }

        return $passed;
    }

    /**
     * Blocks add-to-cart. Store API expects a thrown RouteException rather than
     * a false return, and it reports the message straight to the shopper.
     *
     * @param \WC_Product      $product
     * @param \WP_REST_Request $request
     */
    public static function validateStoreApiAdd($product, $request): void
    {
        if (!$product instanceof \WC_Product) {
            return;
        }

        $quantity = (int) ($request['quantity'] ?? 1);

        self::assertStoreApiQuantity($product, $quantity, true);
    }

    /**
     * Blocks quantity change on an existing line.
     *
     * @param array<string, mixed> $cartItem
     * @param \WP_REST_Request     $request
     */
    public static function validateStoreApiItem($cartItem, $request): void
    {
        if (!is_array($cartItem) || empty($cartItem['data']) || !$cartItem['data'] instanceof \WC_Product) {
            return;
        }

        self::assertStoreApiQuantity($cartItem['data'], (int) ($cartItem['quantity'] ?? 1), false);
    }

    /**
     * Throws the exception type the Store API turns into a shopper-facing
     * error, guarded so the class is only referenced when Blocks is present.
     */
    private static function assertStoreApiQuantity(\WC_Product $product, int $quantity, bool $addingToExisting): void
    {
        $id       = $product->get_id();
        $parentId = $product->get_parent_id();

        $productId   = $parentId ?: $id;
        $variationId = $parentId ? $id : null;

        $rule = self::forProduct((int) $productId, $variationId ? (int) $variationId : null);

        if (!$rule) {
            return;
        }

        $total = $addingToExisting
            ? self::cartQuantityForRule($rule, (int) $productId, (int) $variationId) + $quantity
            : $quantity;

        $violation = $rule->violationFor($total, $product->get_name());

        if ($violation === null) {
            return;
        }

        if (class_exists('\Automattic\WooCommerce\StoreApi\Exceptions\RouteException')) {
            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
                'smb2b_quantity_rule',
                esc_html($violation),
                400
            );
        }
    }

    /**
     * Classic cart and checkout re-check.
     */
    public static function validateCartItems(): void
    {
        foreach (self::violations() as $violation) {
            wc_add_notice($violation, 'error');
        }
    }

    /**
     * Blocks checkout re-check. Store API collects errors in a WP_Error rather
     * than the notice queue, so the same violations are reported here.
     *
     * @param \WP_Error $errors
     */
    public static function validateStoreApiCart($errors, $cart): void
    {
        if (!$errors instanceof \WP_Error) {
            return;
        }

        foreach (self::violations($cart instanceof \WC_Cart ? $cart : null) as $violation) {
            $errors->add('smb2b_quantity_rule', $violation);
        }
    }

    /**
     * Every quantity rule the current cart breaks.
     *
     * @return array<int, string>
     */
    private static function violations(?\WC_Cart $cart = null): array
    {
        $cart = $cart ?: (function_exists('WC') && WC()->cart ? WC()->cart : null);

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

        // A rule caps the whole thing it governs, not one cart line: a max of 10
        // must not be beaten by splitting a product across two lines (add-on
        // data makes distinct cart keys) or, for a parent-scoped rule, across
        // two variations. So quantities are summed into one bucket per governed
        // unit and each bucket is judged once against its rule.
        //
        // @var array<string, array{rule:QuantityRule, qty:int, product:int, variation:int}> $buckets
        $buckets = [];

        foreach ($cart->get_cart() as $itemKey => $item) {
            // A BOGO gift line is added by the promotion engine, not the buyer,
            // and is deliberately exempt from min/max/pack rules — a two-unit
            // free gift must never trip a minimum-of-ten.
            if (!empty($item[BogoRewards::FLAG])) {
                continue;
            }

            $productId   = (int) ($item['product_id'] ?? 0);
            $variationId = (int) ($item['variation_id'] ?? 0);
            $quantity    = (int) ($item['quantity'] ?? 0);

            $rule = self::forProduct($productId, $variationId ?: null);

            if (!$rule) {
                continue;
            }

            if ($rule->scope === QuantityRule::SCOPE_PRODUCT) {
                [$unitType, $unitId] = self::productUnit($rule, $productId, $variationId);
                $key                 = 'r' . (int) $rule->id . '|' . $unitType . $unitId;
                $displayVariation    = $unitType === 'v' ? $variationId : 0;
            } else {
                // Broader scopes (category/global) are Pro's to define totals for;
                // core leaves them judged per line, exactly as before.
                $key              = 'line|' . $itemKey;
                $displayVariation = $variationId;
            }

            if (!isset($buckets[$key])) {
                $buckets[$key] = [
                    'rule'      => $rule,
                    'qty'       => 0,
                    'product'   => $productId,
                    'variation' => $displayVariation,
                ];
            }

            $buckets[$key]['qty'] += $quantity;
        }

        $violations = [];

        foreach ($buckets as $bucket) {
            $violation = $bucket['rule']->violationFor(
                $bucket['qty'],
                self::productName($bucket['product'], $bucket['variation'])
            );

            if ($violation !== null) {
                $violations[] = $violation;
            }
        }

        return $violations;
    }

    /**
     * How a product-scope rule's quantities are pooled: a variation-specific
     * rule caps only that variation, while a rule set on the parent product caps
     * the product as a whole, across every variation. Returns [type, id] where
     * type is 'v' (variation) or 'p' (product).
     *
     * @return array{0:string, 1:int}
     */
    private static function productUnit(QuantityRule $rule, int $productId, int $variationId): array
    {
        if ($variationId) {
            // Compare against the default-language ids so a rule created on the
            // original variation still recognises its translations.
            $variationDefaults = array_map('intval', Multilingual::productIds([$variationId]));

            if (in_array((int) $rule->object_id, $variationDefaults, true)) {
                return ['v', $variationId];
            }
        }

        return ['p', $productId];
    }

    private static function ruleForProductObject(\WC_Product $product): ?QuantityRule
    {
        $id       = $product->get_id();
        $parentId = $product->get_parent_id();

        return $parentId
            ? self::forProduct((int) $parentId, (int) $id)
            : self::forProduct((int) $id);
    }

    /**
     * The quantity already in the cart that counts toward one rule — the whole
     * product for a parent-scoped rule (summed across variations), or just the
     * one variation for a variation-specific rule. Keeps add-time validation in
     * step with how violations() pools quantities at checkout. Public so the
     * bulk-order form can judge each line on the running total, not in isolation.
     */
    public static function cartQuantityForRule(QuantityRule $rule, int $productId, int $variationId): int
    {
        if ($rule->scope === QuantityRule::SCOPE_PRODUCT) {
            [$unitType] = self::productUnit($rule, $productId, $variationId);

            // A parent-scoped rule pools every variation of the product: pass
            // variation 0 so cartQuantityFor sums across them all.
            if ($unitType === 'p') {
                return self::cartQuantityFor($productId, 0);
            }
        }

        return self::cartQuantityFor($productId, $variationId);
    }

    /**
     * Public seam: Pro's tier progress bar reads the same cart quantity the
     * rule engine enforces, so the nudge can never disagree with checkout.
     */
    public static function cartQuantityFor(int $productId, int $variationId): int
    {
        if (!function_exists('WC') || !WC()->cart) {
            return 0;
        }

        $quantity = 0;

        foreach (WC()->cart->get_cart() as $item) {
            // Gift lines are not part of what the buyer chose to order.
            if (!empty($item[BogoRewards::FLAG])) {
                continue;
            }

            if ((int) ($item['product_id'] ?? 0) !== $productId) {
                continue;
            }

            if ($variationId && (int) ($item['variation_id'] ?? 0) !== $variationId) {
                continue;
            }

            $quantity += (int) ($item['quantity'] ?? 0);
        }

        return $quantity;
    }

    private static function productName(int $productId, int $variationId): string
    {
        $product = wc_get_product($variationId ?: $productId);

        return $product ? $product->get_name() : __('This product', 'softminal-b2b-wholesale-for-woocommerce');
    }
}
