<?php

/**
 * Member Discount Engine
 *
 * Evaluates discount rules for qualifying members and applies pricing
 * modifications via WooCommerce hooks. Uses the shared ConditionEvaluator
 * for membership condition checks and provides request-level caching.
 *
 * Flow:
 * 1. On each page request, the engine loads discount rules from settings.
 * 2. For the current user, it pre-evaluates which rules they qualify for (once per request).
 * 3. When WooCommerce asks for a product price, the engine checks qualifying rules
 *    against the product and returns the best discount.
 *
 * Third-party hooks:
 * - arraysubs_discount_rules             — filter the raw rules before evaluation
 * - arraysubs_member_discount_qualifying — filter the qualifying rules for a user
 * - arraysubs_member_discount_rule_matches_product — filter product/rule matching
 * - arraysubs_member_discount_amount     — filter the computed discount for a product
 * - arraysubs_member_cart_discount_amount — filter the computed per-cart discount amount
 * - arraysubs_member_cart_discount_label  — filter the cart fee label
 * - arraysubs_member_discount_cart_item_context — filter resolved cart item context for discount evaluation
 * - arraysubs_member_discount_skip_cart_item — filter whether a cart item should be skipped during per-cart evaluation
 * - arraysubs_member_discount_price      — filter the final discounted price
 * - arraysubs_member_discount_applied    — action fired after a discount is applied to a price
 * - arraysubs_member_discount_stack_with_sale — filter whether member discounts stack on top of sale prices
 * - arraysubs_member_discount_stack_with_coupons — filter whether member discounts coexist with coupons
 *
 * @package ArraySubs\Features\MembersAccess\Services
 * @since 1.9.0
 */

namespace ArraySubs\Features\MembersAccess\Services;

defined('ABSPATH') || exit;

/**
 * DiscountEngine class
 */
class DiscountEngine
{
    /**
     * Qualifying rules cache per user (request-level).
     *
     * @var array<int, array>
     */
    private static array $qualifying_cache = [];

    /**
     * Discounted price cache per user+product+mode (request-level).
     *
     * @var array<string, float|false>
     */
    private static array $price_cache = [];

    /**
     * Re-entrancy guard to prevent infinite loops.
     *
     * @var bool
     */
    private static bool $calculating = false;

    /**
     * Loaded discount rules (request-level).
     *
     * @var array|null
     */
    private static ?array $rules = null;

    // ──────────────────────────────────────────────
    // Public API.
    // ──────────────────────────────────────────────

    /**
     * Get the discounted price for a product.
     *
     * Returns the adjusted price if the current user qualifies, or false
     * when no discount applies. Callers should compare with the original
     * price before using the result.
     *
     * @param \WC_Product $product  WooCommerce product.
     * @param int         $user_id  User ID (0 = guest).
     * @return float|false Discounted price, or false when no discount applies.
     */
    public static function getDiscountedPrice(\WC_Product $product, int $user_id)
    {
        return self::getDiscountedPriceForMode($product, $user_id, 'per_item');
    }

    /**
     * Get the discounted price for a product and apply mode.
     *
     * @param \WC_Product $product  WooCommerce product.
     * @param int         $user_id  User ID (0 = guest).
     * @param string      $apply_to Apply mode to evaluate.
     * @return float|false Discounted price, or false when no discount applies.
     */
    public static function getDiscountedPriceForMode(\WC_Product $product, int $user_id, string $apply_to = 'per_item')
    {
        // Guests never qualify
        if ($user_id <= 0) {
            return false;
        }

        // Re-entrancy guard — prevents infinite loops when WC internally
        // calls get_price() inside our hooks
        if (self::$calculating) {
            return false;
        }

        $product_id = $product->get_id();
        $cache_key  = $user_id . '_' . $product_id . '_' . $apply_to;

        if (array_key_exists($cache_key, self::$price_cache)) {
            return self::$price_cache[$cache_key];
        }

        self::$calculating = true;

        try {
            $result = self::computeDiscountedPrice($product, $user_id, $apply_to);
        } finally {
            self::$calculating = false;
        }

        self::$price_cache[$cache_key] = $result;

        return $result;
    }

    /**
     * Check whether a user qualifies for any discount rule.
     *
     * Lightweight check — does not resolve per-product targeting.
     *
     * @param int $user_id User ID.
     * @return bool
     */
    public static function userHasDiscounts(int $user_id): bool
    {
        if ($user_id <= 0) {
            return false;
        }

        return !empty(self::getQualifyingRules($user_id));
    }

    /**
     * Get the cart-level member discount to apply as a negative fee.
     *
     * Per-cart rules are evaluated against the eligible cart subtotal, then any
     * already-applied per-item member savings for those same items are deducted
     * so discounts never stack on the same scope.
     *
     * @param \WC_Cart $cart    WooCommerce cart.
     * @param int      $user_id Current user ID.
     * @return array{amount: float, rule: array|null}
     */
    public static function getCartDiscountData(\WC_Cart $cart, int $user_id): array
    {
        if ($user_id <= 0) {
            return [
                'amount' => 0.0,
                'rule'   => null,
            ];
        }

        $qualifying = self::getQualifyingRules($user_id);
        if (empty($qualifying)) {
            return [
                'amount' => 0.0,
                'rule'   => null,
            ];
        }

        $best_amount = 0.0;
        $best_rule   = null;
        $cart_items  = $cart->get_cart();

        foreach ($qualifying as $rule) {
            if (($rule['apply_to'] ?? 'per_item') !== 'per_cart') {
                continue;
            }

            $eligible_subtotal   = 0.0;
            $existing_item_saves = 0.0;

            foreach ($cart_items as $cart_item_key => $cart_item) {
                $context = self::getCartItemContext($cart_item, $cart_item_key, $cart_items, $user_id);
                if ($context === null) {
                    continue;
                }

                $product = $context['product'];
                $qty     = $context['quantity'];

                if (!self::productMatchesRule($context['product_id'], $context['parent_id'], $product, $rule)) {
                    continue;
                }

                $base_price = $context['unit_subtotal'];
                if ($base_price <= 0) {
                    continue;
                }

                $eligible_subtotal += ($base_price * $qty);

                $per_item_price = self::getDiscountedPriceForMode($product, $user_id, 'per_item');
                if ($per_item_price !== false && $per_item_price < $base_price) {
                    $existing_item_saves += (($base_price - $per_item_price) * $qty);
                }
            }

            if ($eligible_subtotal <= 0) {
                continue;
            }

            $discount_amount = self::calculateCartRuleDiscount($eligible_subtotal, $rule, $user_id);
            $discount_amount = max(0, $discount_amount - $existing_item_saves);

            if ($discount_amount > $best_amount) {
                $best_amount = $discount_amount;
                $best_rule   = $rule;
            }
        }

        return [
            'amount' => round($best_amount, wc_get_price_decimals()),
            'rule'   => $best_rule,
        ];
    }

    /**
     * Build the effective cart item context used for per-cart discount evaluation.
     *
     * This gives third-party product/container plugins a stable extension point to
     * remap child items to parent products or skip hidden/zero-priced child lines.
     *
     * @param array  $cart_item     Cart item data.
     * @param string $cart_item_key Cart item key.
     * @param array  $cart_items    Full cart contents.
     * @param int    $user_id       Current user ID.
     * @return array<string, mixed>|null
     */
    private static function getCartItemContext(array $cart_item, string $cart_item_key, array $cart_items, int $user_id): ?array
    {
        if (empty($cart_item['data']) || !($cart_item['data'] instanceof \WC_Product)) {
            return null;
        }

        $product         = $cart_item['data'];
        $quantity        = max(1, absint($cart_item['quantity'] ?? 1));
        $line_subtotal   = isset($cart_item['line_subtotal']) ? (float) $cart_item['line_subtotal'] : null;
        $container_key   = self::getContainerParentCartItemKey($cart_item);
        $is_child_line   = !empty($container_key);
        $fallback_amount = self::getBasePrice($product, $user_id) * $quantity;
        $eligible_total  = ($line_subtotal !== null && $line_subtotal > 0) ? $line_subtotal : $fallback_amount;
        $unit_subtotal   = $quantity > 0 ? ($eligible_total / $quantity) : 0.0;

        $context = [
            'cart_item_key'       => $cart_item_key,
            'cart_item'           => $cart_item,
            'product'             => $product,
            'product_id'          => $product->get_id(),
            'parent_id'           => $product->get_parent_id(),
            'quantity'            => $quantity,
            'line_subtotal'       => $line_subtotal,
            'eligible_subtotal'   => $eligible_total,
            'unit_subtotal'       => $unit_subtotal,
            'container_parent_key' => $container_key,
            'is_container_child'  => $is_child_line,
            'container_parent'    => ($container_key && isset($cart_items[$container_key])) ? $cart_items[$container_key] : null,
        ];

        /**
         * Filter the resolved cart item context used during per-cart discount evaluation.
         *
         * Third-party bundle/composite/container plugins can remap the effective
         * product IDs here or provide custom subtotal logic for child items.
         *
         * @since 1.9.0
         *
         * @param array  $context       Resolved cart item context.
         * @param array  $cart_item     Original cart item.
         * @param string $cart_item_key Cart item key.
         * @param int    $user_id       Current user ID.
         */
        $context = apply_filters(
            'arraysubs_member_discount_cart_item_context',
            $context,
            $cart_item,
            $cart_item_key,
            $user_id
        );

        if (empty($context['product']) || !($context['product'] instanceof \WC_Product)) {
            return null;
        }

        $context_line_subtotal = isset($context['line_subtotal']) ? (float) $context['line_subtotal'] : $line_subtotal;
        $context_unit_subtotal = isset($context['unit_subtotal']) ? (float) $context['unit_subtotal'] : $unit_subtotal;
        $context_is_child_line = !empty($context['is_container_child']);

        $should_skip = $context_is_child_line
            && (($context_line_subtotal !== null && $context_line_subtotal <= 0) || $context_unit_subtotal <= 0);

        /**
         * Filter whether a cart item should be skipped during per-cart discount evaluation.
         *
         * Defaults to skipping linked container child lines that have no standalone
         * subtotal, which avoids double-discounting bundled/composite children.
         *
         * @since 1.9.0
         *
         * @param bool  $should_skip Whether the cart item should be skipped.
         * @param array $context     Resolved cart item context.
         * @param array $cart_item   Original cart item.
         * @param int   $user_id     Current user ID.
         */
        $should_skip = (bool) apply_filters(
            'arraysubs_member_discount_skip_cart_item',
            $should_skip,
            $context,
            $cart_item,
            $user_id
        );

        return $should_skip ? null : $context;
    }

    /**
     * Get all qualifying rules for a user.
     *
     * The result is cached per request so condition evaluation only runs once,
     * regardless of how many products are displayed.
     *
     * @param int $user_id User ID.
     * @return array Array of qualifying discount rule arrays.
     */
    public static function getQualifyingRules(int $user_id): array
    {
        if (isset(self::$qualifying_cache[$user_id])) {
            return self::$qualifying_cache[$user_id];
        }

        $rules = self::getRules();
        if (empty($rules)) {
            self::$qualifying_cache[$user_id] = [];
            return [];
        }

        $evaluator          = new ConditionEvaluator();
        $schedule_evaluator = new ScheduleEvaluator();
        $qualifying         = [];

        foreach ($rules as $rule) {
            if (empty($rule['enabled'])) {
                continue;
            }

            $conditions = $rule['conditions'] ?? [];
            if (empty($conditions['rules'])) {
                // No conditions ⇒ rule applies to everyone logged-in
                // Still check dripping schedule
                if ($schedule_evaluator->isScheduleSatisfied($user_id, $rule)) {
                    $qualifying[] = $rule;
                }
                continue;
            }

            if ($evaluator->evaluate($user_id, $conditions)) {
                // Conditions pass — check dripping schedule
                if ($schedule_evaluator->isScheduleSatisfied($user_id, $rule)) {
                    $qualifying[] = $rule;
                }
            }
        }

        /**
         * Filter the qualifying discount rules for a user.
         *
         * Allows third-party plugins to add, remove, or modify which discount
         * rules a specific user qualifies for.
         *
         * @since 1.9.0
         *
         * @param array $qualifying Array of qualifying rule arrays.
         * @param int   $user_id    The user being evaluated.
         * @param array $rules      The full set of enabled discount rules.
         */
        $qualifying = apply_filters('arraysubs_member_discount_qualifying', $qualifying, $user_id, $rules);

        self::$qualifying_cache[$user_id] = $qualifying;

        return $qualifying;
    }

    /**
     * Clear all request-level caches.
     *
     * Called on user switch or when rules change.
     *
     * @return void
     */
    public static function clearCache(): void
    {
        self::$qualifying_cache = [];
        self::$price_cache      = [];
        self::$rules            = null;
    }

    // ──────────────────────────────────────────────
    // Internal helpers.
    // ──────────────────────────────────────────────

    /**
     * Get discount rules from settings.
     *
     * @return array
     */
    private static function getRules(): array
    {
        if (self::$rules !== null) {
            return self::$rules;
        }

        $rules = arraysubs_get_setting('members_access.discount_rules', []);

        if (!is_array($rules)) {
            $rules = [];
        }

        /**
         * Filter discount rules before evaluation.
         *
         * Allows third-party plugins to inject or modify discount rules
         * programmatically (e.g. time-limited promotions, external CRM tiers).
         *
         * @since 1.9.0
         *
         * @param array $rules The raw discount rules from settings.
         */
        self::$rules = apply_filters('arraysubs_discount_rules', $rules);

        return self::$rules;
    }

    /**
     * Compute the discounted price for a specific product and user.
     *
     * When multiple rules match the same product the best (highest) discount
     * wins — no stacking across rules.
     *
     * @param \WC_Product $product  WooCommerce product.
     * @param int         $user_id  User ID.
     * @param string      $apply_to Apply mode to evaluate.
     * @return float|false
     */
    private static function computeDiscountedPrice(\WC_Product $product, int $user_id, string $apply_to = 'per_item')
    {
        $qualifying = self::getQualifyingRules($user_id);
        if (empty($qualifying)) {
            return false;
        }

        $base_price = self::getBasePrice($product, $user_id);
        if ($base_price <= 0) {
            return false;
        }
        $sale_price      = $product->get_sale_price('edit');
        $regular_price   = (float) $product->get_regular_price('edit');
        $stack_with_sale = self::shouldStackWithSale($product, $user_id);
        $has_sale        = ($sale_price !== '' && (float) $sale_price > 0 && (float) $sale_price < $regular_price);

        $product_id = $product->get_id();
        $parent_id  = $product->get_parent_id();

        // Collect the best discount across all qualifying rules
        $best_price = null;

        foreach ($qualifying as $rule) {
            if (($rule['apply_to'] ?? 'per_item') !== $apply_to) {
                continue;
            }

            if (!self::productMatchesRule($product_id, $parent_id, $product, $rule)) {
                continue;
            }

            $discount_type  = $rule['discount_type'] ?? 'percentage';
            $discount_value = (float) ($rule['discount_value'] ?? 0);

            if ($discount_value <= 0) {
                continue;
            }

            /**
             * Filter the discount amount before applying.
             *
             * @since 1.9.0
             *
             * @param float       $discount_value The raw discount value.
             * @param string      $discount_type  'percentage' or 'fixed'.
             * @param array       $rule           The discount rule array.
             * @param \WC_Product $product        The product.
             * @param int         $user_id        The user ID.
             */
            $discount_value = (float) apply_filters(
                'arraysubs_member_discount_amount',
                $discount_value,
                $discount_type,
                $rule,
                $product,
                $user_id
            );

            if ($discount_value <= 0) {
                continue;
            }

            $price = self::calculateDiscountedPriceFromRule(
                $base_price,
                $discount_type,
                $discount_value
            );

            // Ensure price never goes below zero
            $price = max(0, $price);

            if ($best_price === null || $price < $best_price) {
                $best_price = $price;
            }
        }

        if ($best_price === null) {
            return false;
        }

        // If member discount is worse than existing sale price, prefer sale price
        if ($has_sale && !$stack_with_sale && (float) $sale_price <= $best_price) {
            return false;
        }

        /**
         * Filter the final discounted price.
         *
         * @since 1.9.0
         *
         * @param float       $best_price The computed discounted price.
         * @param float       $base_price The base price used for the calculation.
         * @param \WC_Product $product    The product.
         * @param int         $user_id    The user ID.
         */
        $best_price = (float) apply_filters(
            'arraysubs_member_discount_price',
            $best_price,
            $base_price,
            $product,
            $user_id
        );

        return max(0, round($best_price, wc_get_price_decimals()));
    }

    /**
     * Check whether a product matches a rule's target scope and exclusions.
     *
     * @param int         $product_id Product (or variation) ID.
     * @param int         $parent_id  Parent product ID (0 for simple products).
     * @param \WC_Product $product    Product object.
     * @param array       $rule       Discount rule.
     * @return bool
     */
    private static function productMatchesRule(int $product_id, int $parent_id, \WC_Product $product, array $rule): bool
    {
        // ── Exclusions first (fast reject) ──────────────
        $effective_id = $parent_id > 0 ? $parent_id : $product_id;

        $exclusion_product_ids  = array_map('absint', $rule['exclusion_product_ids'] ?? []);
        $exclusion_category_ids = array_map('absint', $rule['exclusion_category_ids'] ?? []);

        if (!empty($exclusion_product_ids)) {
            if (in_array($product_id, $exclusion_product_ids, true) || in_array($effective_id, $exclusion_product_ids, true)) {
                return false;
            }
        }

        if (!empty($exclusion_category_ids)) {
            $product_cats = self::getProductCategoryIds($effective_id);
            if (array_intersect($product_cats, $exclusion_category_ids)) {
                return false;
            }
        }

        // ── Target scope ────────────────────────────────
        $scope = $rule['target_scope'] ?? 'all';

        if ($scope === 'all') {
            $matches = true;

            return (bool) apply_filters(
                'arraysubs_member_discount_rule_matches_product',
                $matches,
                $rule,
                $product,
                $product_id,
                $parent_id
            );
        }

        if ($scope === 'specific_products') {
            $target_ids = array_map('absint', $rule['target_product_ids'] ?? []);
            $matches = in_array($product_id, $target_ids, true) || in_array($effective_id, $target_ids, true);

            return (bool) apply_filters(
                'arraysubs_member_discount_rule_matches_product',
                $matches,
                $rule,
                $product,
                $product_id,
                $parent_id
            );
        }

        if ($scope === 'specific_categories') {
            $target_cat_ids = array_map('absint', $rule['target_category_ids'] ?? []);
            if (empty($target_cat_ids)) {
                return false;
            }
            $product_cats = self::getProductCategoryIds($effective_id);
            $matches = !empty(array_intersect($product_cats, $target_cat_ids));

            return (bool) apply_filters(
                'arraysubs_member_discount_rule_matches_product',
                $matches,
                $rule,
                $product,
                $product_id,
                $parent_id
            );
        }

        if ($scope === 'specific_tags') {
            $target_tag_ids = array_map('absint', $rule['target_tag_ids'] ?? []);
            if (empty($target_tag_ids)) {
                return false;
            }
            $product_tags = self::getProductTagIds($effective_id);
            $matches = !empty(array_intersect($product_tags, $target_tag_ids));

            return (bool) apply_filters(
                'arraysubs_member_discount_rule_matches_product',
                $matches,
                $rule,
                $product,
                $product_id,
                $parent_id
            );
        }

        return (bool) apply_filters(
            'arraysubs_member_discount_rule_matches_product',
            false,
            $rule,
            $product,
            $product_id,
            $parent_id
        );
    }

    /**
     * Resolve the base price used for member discount calculations.
     *
     * @param \WC_Product $product Product object.
     * @param int         $user_id Current user ID.
     * @return float
     */
    private static function getBasePrice(\WC_Product $product, int $user_id): float
    {
        $regular_price = (float) $product->get_regular_price('edit');
        if ($regular_price <= 0) {
            return 0.0;
        }

        $sale_price = $product->get_sale_price('edit');
        $has_sale   = ($sale_price !== '' && (float) $sale_price > 0 && (float) $sale_price < $regular_price);

        if ($has_sale && self::shouldStackWithSale($product, $user_id)) {
            return (float) $sale_price;
        }

        return $regular_price;
    }

    /**
     * Determine whether member discounts stack on WooCommerce sale prices.
     *
     * @param \WC_Product $product Product object.
     * @param int         $user_id Current user ID.
     * @return bool
     */
    private static function shouldStackWithSale(\WC_Product $product, int $user_id): bool
    {
        return (bool) apply_filters('arraysubs_member_discount_stack_with_sale', false, $product, $user_id);
    }

    /**
     * Calculate the discounted product price from a rule definition.
     *
     * @param float  $base_price      Base price.
     * @param string $discount_type   Discount type.
     * @param float  $discount_value  Discount value.
     * @return float
     */
    private static function calculateDiscountedPriceFromRule(float $base_price, string $discount_type, float $discount_value): float
    {
        if ($discount_type === 'percentage') {
            $discount_value = min($discount_value, 100);

            return max(0, $base_price - ($base_price * ($discount_value / 100)));
        }

        return max(0, $base_price - min($discount_value, $base_price));
    }

    /**
     * Calculate the discount amount for a per-cart rule.
     *
     * @param float $eligible_subtotal Eligible cart subtotal.
     * @param array $rule              Discount rule.
     * @param int   $user_id           Current user ID.
     * @return float
     */
    private static function calculateCartRuleDiscount(float $eligible_subtotal, array $rule, int $user_id): float
    {
        $discount_type  = $rule['discount_type'] ?? 'fixed';
        $discount_value = (float) ($rule['discount_value'] ?? 0);

        if ($discount_value <= 0 || $eligible_subtotal <= 0) {
            return 0.0;
        }

        if ($discount_type === 'percentage') {
            $discount_value = min($discount_value, 100);
            $discount_value = $eligible_subtotal * ($discount_value / 100);
        }

        $discount_value = min($discount_value, $eligible_subtotal);

        /**
         * Filter the computed per-cart member discount amount.
         *
         * @since 1.9.0
         *
         * @param float $discount_value    The computed discount amount.
         * @param float $eligible_subtotal The eligible subtotal for the matched cart items.
         * @param array $rule              The discount rule array.
         * @param int   $user_id           The current user ID.
         */
        return (float) apply_filters(
            'arraysubs_member_cart_discount_amount',
            $discount_value,
            $eligible_subtotal,
            $rule,
            $user_id
        );
    }

    /**
     * Get the linked container/parent cart item key for common product plugin patterns.
     *
     * @param array $cart_item Cart item data.
     * @return string
     */
    private static function getContainerParentCartItemKey(array $cart_item): string
    {
        $candidate_keys = [
            'bundled_by',
            'composite_parent',
            'mnm_container',
            'container_cart_item_key',
            'woosb_parent_key',
            'woosb_parent',
            'force_sell_parent',
        ];

        /**
         * Filter supported cart item keys used to detect linked container/parent lines.
         *
         * @since 1.9.0
         *
         * @param array $candidate_keys List of cart item meta keys that store a parent cart item key.
         * @param array $cart_item       Cart item data.
         */
        $candidate_keys = apply_filters('arraysubs_member_discount_container_cart_item_keys', $candidate_keys, $cart_item);

        if (!is_array($candidate_keys)) {
            return '';
        }

        foreach ($candidate_keys as $key) {
            if (!empty($cart_item[$key]) && is_string($cart_item[$key])) {
                return $cart_item[$key];
            }
        }

        return '';
    }

    /**
     * Get product category IDs.
     *
     * @param int $product_id Product ID (simple or parent).
     * @return int[]
     */
    private static function getProductCategoryIds(int $product_id): array
    {
        $terms = get_the_terms($product_id, 'product_cat');
        if (is_wp_error($terms) || empty($terms)) {
            return [];
        }

        return wp_list_pluck($terms, 'term_id');
    }

    /**
     * Get product tag IDs.
     *
     * @param int $product_id Product ID (simple or parent).
     * @return int[]
     */
    private static function getProductTagIds(int $product_id): array
    {
        $terms = get_the_terms($product_id, 'product_tag');
        if (is_wp_error($terms) || empty($terms)) {
            return [];
        }

        return wp_list_pluck($terms, 'term_id');
    }
}
