<?php

namespace App\Domain\Pricing\Services;

use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Enums\AdjustmentType;
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Pricing\Models\Promotion;
use App\Domain\Pricing\Support\ConditionSchema;
use App\Domain\Pricing\Support\Percent;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\SystemSetting;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;

class PricingService
{
    /**
     * Maximum discount percentage applied globally (academy setting, default 50%).
     */
    private const DEFAULT_MAX_DISCOUNT_PERCENT = 50;

    /**
     * The 10-step price resolution engine.
     *
     * @param Model            $priceable   The item being priced (TrainingProgram, Product, etc.)
     * @param Participant|null $participant The buyer (null for anonymous walk-in product sales)
     * @param int|null         $branchId    Override branch (falls back to participant's branch)
     * @param string|null      $couponCode  Coupon code to validate and apply
     * @param string|null      $date        Pricing date (defaults to today)
     *
     * @throws DomainException When no base price is found
     */
    public function calculate(
        Model $priceable,
        ?Participant $participant = null,
        ?int $branchId = null,
        ?string $couponCode = null,
        ?string $date = null,
        array $extraContext = [],
        ?array $contextOverride = null
    ): PriceResult {
        $date ??= now()->toDateString();
        $branchId ??= $this->resolveParticipantBranch($participant);

        // Membership type: explicit override (new registration, no row yet) wins.
        $membershipType = $contextOverride['membership_type']
            ?? $participant?->membership_type?->value
            ?? 'non_member';

        // ─── Step 1: Find base price (fail if none exists) ────────────
        $basePrice = $this->findBasePrice($priceable, $branchId, $date, $participant, $membershipType);
        $originalAmount = $this->resolveBaseAmount($priceable, $basePrice, $membershipType);

        if ($originalAmount === null) {
            throw new DomainException('لا يوجد سعر محدد');
        }

        // The row only stands as the source when its amount is the one taken.
        if ($basePrice && $basePrice->amount !== $originalAmount) {
            $basePrice = null;
        }

        $currentAmount = $originalAmount;
        $appliedRules = [];
        $totalDiscount = 0;

        // ─── Step 2: Gather applicable rules ──────────────────────────
        $rules = $this->gatherApplicableRules($priceable, $branchId, $date);

        // ─── Step 3: Evaluate conditions against participant context ──
        // A provisional context lets new-registration price correctly BEFORE the
        // participant row exists — otherwise the busiest flow cannot use the engine.
        if ($contextOverride !== null) {
            $context = array_merge($contextOverride, $extraContext);
            // The tier was already resolved above for the base price. A
            // membership_type rule must read the same answer, or the discount
            // and the price it discounts would disagree about who this is.
            $context['membership_type'] ??= $membershipType;
            $rules = $rules->filter(fn (PricingRule $rule) => $this->evaluateConditions($rule, $context));
        } elseif ($participant) {
            $context = array_merge($this->buildParticipantContext($participant), $extraContext);
            $context['membership_type'] ??= $membershipType;
            $rules = $rules->filter(fn (PricingRule $rule) => $this->evaluateConditions($rule, $context));
        } else {
            // No participant and no context: only unconditional rules apply
            $rules = $rules->filter(fn (PricingRule $rule) => empty($rule->conditions));
        }

        // ─── Step 4: Filter non-stackable conflicts ──────────────────
        $rules = $this->filterNonStackableConflicts($rules);

        // ─── Step 5: Apply rules in priority order (ASC) ─────────────
        $rules = $rules->sortBy('priority')->values();

        foreach ($rules as $rule) {
            $adjustedAmount = $this->applyAdjustment($currentAmount, $originalAmount, $rule);

            // ─── Step 6: Apply rule-level caps ────────────────────────
            if ($rule->max_discount_percent !== null) {
                $maxAllowedDiscount = (int) floor($originalAmount * $rule->max_discount_percent / 100);
                $ruleDiscount = $currentAmount - $adjustedAmount;
                if ($ruleDiscount > $maxAllowedDiscount) {
                    $adjustedAmount = $currentAmount - $maxAllowedDiscount;
                }
            }

            // Only record if the rule actually changed the amount
            if ($adjustedAmount !== $currentAmount) {
                $ruleDiscount = $currentAmount - $adjustedAmount;
                $appliedRules[] = [
                    'rule_id' => $rule->id,
                    'rule_name' => $rule->name_ar,
                    'rule_type' => $rule->rule_type->value,
                    'adjustment_type' => $rule->adjustment_type->value,
                    'before' => $currentAmount,
                    'after' => $adjustedAmount,
                    'discount' => $ruleDiscount,
                ];
                $totalDiscount += $ruleDiscount;
                $currentAmount = $adjustedAmount;
            }
        }

        // ─── Step 7: Apply promotion/coupon (if any) ─────────────────
        $appliedPromotion = null;
        if ($couponCode) {
            $promotion = $this->validateCoupon($couponCode, $participant, $currentAmount, $priceable, $branchId);
            if ($promotion) {
                $promoAdjusted = $this->applyPromotionAdjustment($currentAmount, $originalAmount, $promotion);

                // Apply max_discount_amount cap on promotion
                if ($promotion->max_discount_amount !== null) {
                    $promoDiscount = $currentAmount - $promoAdjusted;
                    if ($promoDiscount > $promotion->max_discount_amount) {
                        $promoAdjusted = $currentAmount - $promotion->max_discount_amount;
                    }
                }

                $promoDiscount = $currentAmount - $promoAdjusted;
                $appliedPromotion = [
                    'promotion_id' => $promotion->id,
                    'code' => $promotion->code,
                    'name' => $promotion->name_ar,
                    'before' => $currentAmount,
                    'after' => $promoAdjusted,
                    'discount' => $promoDiscount,
                ];
                $totalDiscount += $promoDiscount;
                $currentAmount = $promoAdjusted;
            }
        }

        // ─── Step 8: Apply global maximum discount ───────────────────
        $maxAllowedTotalDiscount = $this->maxDiscountFor($originalAmount);
        if ($totalDiscount > $maxAllowedTotalDiscount) {
            $currentAmount = $originalAmount - $maxAllowedTotalDiscount;
            $totalDiscount = $maxAllowedTotalDiscount;
        }

        // ─── Step 9: Floor check — never negative ────────────────────
        if ($currentAmount < 0) {
            $currentAmount = 0;
            $totalDiscount = $originalAmount;
        }

        // ─── Step 10: Return PriceResult ─────────────────────────────
        return new PriceResult(
            baseAmount: $originalAmount,
            finalAmount: $currentAmount,
            totalDiscount: $totalDiscount,
            appliedRules: $appliedRules,
            appliedPromotion: $appliedPromotion,
            basePriceId: $basePrice?->id,
        );
    }

    // ─── Step 1: Base Price Resolution ────────────────────────────────────

    /**
     * The amount the whole calculation starts from, once every place a price
     * can be configured has had its say.
     *
     * Two surfaces set prices in this system and they disagreed silently. The
     * pricing screen writes `base_prices` rows, optionally tagged with a
     * membership type. The product screen writes `products.member_price` /
     * `non_member_price` — and nothing ever read them outside the registration
     * wizard, so every other till charged members the walk-in rate and no line
     * on the receipt said so.
     *
     * They are ranked by how specifically they answer *this* buyer:
     *
     *   1. a base price tagged with this membership type — someone configured
     *      it for exactly this case, so nothing outranks it;
     *   2. the product's own column for this tier — a deliberate member rate;
     *   3. any other base price — the untagged row, or another branch's;
     *
     * and null when none of the three exists, which is the hard fail the rules
     * require. `products.selling_price` is deliberately not in the list: it is
     * the catalogue's advertised number, not a configured price, and letting it
     * in here would make "no price set" impossible to detect.
     */
    private function resolveBaseAmount(Model $priceable, ?BasePrice $basePrice, string $membershipType): ?int
    {
        $basePriceIsForThisTier = $basePrice
            && ($basePrice->metadata['membership_type'] ?? null) === $membershipType;

        if ($basePriceIsForThisTier) {
            return (int) $basePrice->amount;
        }

        $tierPrice = $priceable instanceof Product
            ? $priceable->explicitTierPrice($membershipType)
            : null;

        if ($tierPrice !== null) {
            return (int) $tierPrice;
        }

        return $basePrice ? (int) $basePrice->amount : null;
    }

    private function findBasePrice(
        Model $priceable,
        ?int $branchId,
        string $date,
        ?Participant $participant = null,
        ?string $membershipTypeOverride = null
    ): ?BasePrice {
        $query = BasePrice::where('priceable_type', $priceable->getMorphClass())
            ->where('priceable_id', $priceable->getKey())
            ->where('is_active', true)
            ->where('effective_from', '<=', $date)
            ->where(function ($q) use ($date) {
                $q->whereNull('effective_to')
                    ->orWhere('effective_to', '>=', $date);
            });

        // Membership type: explicit override (new registration, no row yet) wins.
        $membershipType = $membershipTypeOverride
            ?? $participant?->membership_type?->value
            ?? 'non_member';

        // Try membership-type-specific price first, then fallback to any
        $withMembership = fn ($q) => (clone $q)->where('metadata->membership_type', $membershipType);

        // Branch-specific + membership type first
        if ($branchId) {
            $match = $withMembership((clone $query)->where('branch_id', $branchId))
                ->orderByDesc('priority')
                ->first();
            if ($match) {
                return $match;
            }

            // Branch-specific without membership filter
            $branchAny = (clone $query)
                ->where('branch_id', $branchId)
                ->orderByDesc('priority')
                ->first();
            if ($branchAny) {
                return $branchAny;
            }
        }

        // No branch: membership type first
        $match = $withMembership((clone $query)->whereNull('branch_id'))
            ->orderByDesc('priority')
            ->first();
        if ($match) {
            return $match;
        }

        // Final fallback: any active base price
        return $query->whereNull('branch_id')
            ->orderByDesc('priority')
            ->first();
    }

    // ─── Step 2: Gather Applicable Rules ──────────────────────────────────

    private function gatherApplicableRules(Model $priceable, ?int $branchId, string $date): Collection
    {
        return PricingRule::where('is_active', true)
            ->where(function ($q) use ($date) {
                $q->whereNull('effective_from')
                    ->orWhere('effective_from', '<=', $date);
            })
            ->where(function ($q) use ($date) {
                $q->whereNull('effective_to')
                    ->orWhere('effective_to', '>=', $date);
            })
            // Manual/approval discounts are chosen in the picker, never auto-applied.
            ->where('is_manual', false)
            ->where(function ($q) use ($branchId) {
                // Academy-wide = no single-branch FK AND no pivot targeting at all.
                $q->where(function ($sq) {
                    $sq->whereNull('branch_id')->whereNotExists(function ($e) {
                        $e->selectRaw('1')->from('pricing_rule_branches')
                            ->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id');
                    });
                });

                if ($branchId) {
                    $q->orWhere('branch_id', $branchId)
                        ->orWhereExists(function ($e) use ($branchId) {
                            $e->selectRaw('1')->from('pricing_rule_branches')
                                ->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id')
                                ->where('pricing_rule_branches.branch_id', $branchId);
                        });
                }
            })
            ->where(function ($q) use ($priceable) {
                // Global rules (no target), type-wide rules, or specific target
                $q->where(function ($sq) {
                    $sq->whereNull('target_type')->whereNull('target_id');
                })
                ->orWhere(function ($sq) use ($priceable) {
                    $sq->where('target_type', $priceable->getMorphClass())
                        ->where('target_id', $priceable->getKey());
                })
                ->orWhere(function ($sq) use ($priceable) {
                    $sq->where('target_type', $priceable->getMorphClass())
                        ->whereNull('target_id');
                });
            })
            ->where(function ($q) {
                // Respect usage limits
                $q->whereNull('usage_limit')
                    ->orWhereColumn('usage_count', '<', 'usage_limit');
            })
            ->get();
    }

    // ─── Step 3: Build Participant Context ────────────────────────────────

    private function buildParticipantContext(Participant $participant): array
    {
        $person = $participant->person;
        $age = $this->completedYearsSince($person?->date_of_birth);

        // Family size: count participants sharing a guardian
        $familySize = 1;
        $siblingOrder = 1;
        if ($participant->relationLoaded('guardians') ? $participant->guardians->isNotEmpty() : $participant->guardians()->exists()) {
            $guardianIds = $participant->guardians->pluck('id');
            if ($guardianIds->isNotEmpty()) {
                $siblings = Participant::whereHas('guardians', function ($q) use ($guardianIds) {
                    $q->whereIn('guardians.id', $guardianIds);
                })->orderBy('created_at')->pluck('id');

                $familySize = max(1, $siblings->count());
                $siblingOrder = $siblings->search($participant->id) + 1;
                if ($siblingOrder < 1) {
                    $siblingOrder = 1;
                }
            }
        }

        // Membership duration in months
        $membershipMonths = $this->completedMonthsSince($participant->created_at) ?? 0;

        // Active enrollment count
        $enrollmentCount = $participant->activeEnrollments()->count();

        return [
            'age' => $age,
            'gender' => $person?->gender,
            'classification' => $participant->classification ?? 'regular',
            // Same default the base-price lookup uses at step 1: an unset tier
            // is a non-member, so a "members only" rule cannot quietly reach
            // someone nobody ever classified.
            'membership_type' => $participant->membership_type?->value ?? 'non_member',
            'membership_duration_months' => $membershipMonths,
            'family_size' => $familySize,
            'sibling_order' => $siblingOrder,
            'enrollment_count' => $enrollmentCount,
            'branch_id' => $this->resolveParticipantBranch($participant),
        ];
    }

    /**
     * Whole years from a past moment until now — an age, a tenure.
     *
     * Carbon 3 signs its differences: `$a->diffIn*($b)` answers `$b − $a`, so
     * `now()->diffInYears($birthday)` returns a *negative* number of years for
     * anyone already born. Cast to int that made every participant's age
     * negative, and the damage ran both ways: a rule with `min` (teens, twelve
     * months' loyalty) stopped matching anyone, while a rule with `max` — the
     * juniors discount is `max: 6` — matched every member of the academy,
     * because −34 is comfortably under six. Read from the older moment forward.
     */
    private function completedYearsSince(mixed $moment): ?int
    {
        return $this->completedUnitsSince($moment, 'diffInYears');
    }

    /** Whole months from a past moment until now. See completedYearsSince(). */
    private function completedMonthsSince(mixed $moment): ?int
    {
        return $this->completedUnitsSince($moment, 'diffInMonths');
    }

    private function completedUnitsSince(mixed $moment, string $method): ?int
    {
        if ($moment === null || $moment === '') {
            return null;
        }

        $moment = $moment instanceof \DateTimeInterface
            ? \Carbon\Carbon::instance($moment)
            : \Carbon\Carbon::parse($moment);

        // A date in the future is not a negative age; it is no age at all.
        return max(0, (int) $moment->{$method}(now()));
    }

    // ─── Step 3: Condition Evaluation ─────────────────────────────────────

    private function evaluateConditions(PricingRule $rule, array $context): bool
    {
        $conditions = $rule->conditions;
        if (empty($conditions)) {
            return true;
        }

        return match ($rule->rule_type) {
            \App\Domain\Pricing\Enums\PricingRuleType::Age => $this->evaluateRange($context['age'], $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::MembershipDuration => $this->evaluateRange($context['membership_duration_months'], $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::FamilySize => $this->evaluateRange($context['family_size'], $conditions)
                && $this->evaluateOptionalTier($context, $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::SiblingOrder => $this->evaluateRange($context['sibling_order'], $conditions)
                && $this->evaluateOptionalTier($context, $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::MembershipType => $this->evaluateInList($context['membership_type'] ?? null, $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::Classification => $this->evaluateInList($context['classification'], $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::EnrollmentVolume => $this->evaluateRange($context['enrollment_count'], $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::Gender => $this->evaluateInList($context['gender'], $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::Branch => $this->evaluateInList($context['branch_id'], $conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::EnrollmentTiming => $this->evaluateTimingCondition($conditions, $context),
            \App\Domain\Pricing\Enums\PricingRuleType::Seasonal => $this->evaluateSeasonalCondition($conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::DayTime => $this->evaluateDayTimeCondition($conditions),
            \App\Domain\Pricing\Enums\PricingRuleType::Loyalty => $this->evaluateRange($context['membership_duration_months'], $conditions),
            // Custom/manual rules are chosen explicitly in the picker — they must
            // never auto-apply, or every participant silently receives them.
            \App\Domain\Pricing\Enums\PricingRuleType::Custom => false,
        };
    }

    private function evaluateRange(?int $value, array $conditions): bool
    {
        if ($value === null) {
            return false;
        }

        $min = $conditions['min'] ?? null;
        $max = $conditions['max'] ?? null;

        if ($min !== null && $value < (int) $min) {
            return false;
        }
        if ($max !== null && $value > (int) $max) {
            return false;
        }

        return true;
    }

    /**
     * The optional membership-tier filter carried by a sibling or family-size rule.
     *
     * A club whose sibling price differs for only one tier — the second child of a
     * non-resident pays less, a resident's price is already the discounted one —
     * has no way to say that with a range alone. Expressing it as a second stacked
     * rule would store that tier's base price in a second place, and the day fees
     * change and only `base_prices` is updated, the rule silently forces the old
     * number back. So the filter lives on the rule that owns the condition.
     *
     * An absent key means both tiers, which is what every rule authored before this
     * existed means — so behaviour is unchanged wherever it was never set.
     */
    private function evaluateOptionalTier(array $context, array $conditions): bool
    {
        if (empty($conditions['membership_type'])) {
            return true;
        }

        return $this->evaluateInList(
            $context['membership_type'] ?? null,
            ['values' => $conditions['membership_type']],
        );
    }

    private function evaluateInList(mixed $value, array $conditions): bool
    {
        $allowed = $conditions['values'] ?? [];

        // Fail CLOSED. An empty allow-list used to mean "match everyone", which
        // turned every mis-authored rule into an academy-wide discount.
        if (empty($allowed) || $value === null) {
            return false;
        }

        return in_array($value, $allowed);
    }

    private function evaluateTimingCondition(array $conditions, array $context = []): bool
    {
        $now = now();

        if (isset($conditions['before_date']) && $now->gt($conditions['before_date'])) {
            return false;
        }
        if (isset($conditions['after_date']) && $now->lt($conditions['after_date'])) {
            return false;
        }

        // Early-bird: only satisfiable when the caller supplied the program start
        // date. Without it the condition fails closed rather than silently passing.
        if (isset($conditions['days_before_start'])) {
            $startsOn = $context['program_starts_on'] ?? null;
            if (! $startsOn) {
                return false;
            }

            return $now->lte(\Carbon\Carbon::parse($startsOn)->subDays((int) $conditions['days_before_start']));
        }

        return true;
    }

    private function evaluateSeasonalCondition(array $conditions): bool
    {
        $months = $conditions['months'] ?? [];
        if (!empty($months) && !in_array(now()->month, $months)) {
            return false;
        }

        return true;
    }

    private function evaluateDayTimeCondition(array $conditions): bool
    {
        $now = now();

        $days = $conditions['days'] ?? [];
        if (!empty($days) && !in_array(strtolower($now->format('l')), $days)) {
            return false;
        }

        $afterHour = $conditions['after_hour'] ?? null;
        $beforeHour = $conditions['before_hour'] ?? null;
        if ($afterHour !== null && $now->hour < (int) $afterHour) {
            return false;
        }
        if ($beforeHour !== null && $now->hour >= (int) $beforeHour) {
            return false;
        }

        return true;
    }

    // ─── Step 4: Non-Stackable Conflict Resolution ────────────────────────

    private function filterNonStackableConflicts(Collection $rules): Collection
    {
        $stackable = $rules->filter(fn (PricingRule $r) => $r->is_stackable);
        $nonStackable = $rules->filter(fn (PricingRule $r) => !$r->is_stackable);

        if ($nonStackable->count() <= 1) {
            return $stackable->merge($nonStackable)->values();
        }

        // Among conflicting non-stackable rules, highest priority wins (lowest number)
        $winner = $nonStackable->sortBy('priority')->first();

        return $stackable->push($winner)->values();
    }

    // ─── Step 5/6: Apply Adjustment ───────────────────────────────────────

    /**
     * Apply a pricing rule adjustment.
     *
     * Percentage values stored in hundredths (basis points):
     * 10% = 1000, 25% = 2500, 50% = 5000, 12.5% = 1250
     */
    private function applyAdjustment(int $currentAmount, int $originalAmount, PricingRule $rule): int
    {
        return match ($rule->adjustment_type) {
            AdjustmentType::PercentageDiscount => $currentAmount - (int) floor($currentAmount * $rule->adjustment_value / 10000),
            AdjustmentType::FixedDiscount => max(0, $currentAmount - $rule->adjustment_value),
            AdjustmentType::FixedPrice => $rule->adjustment_value,
            AdjustmentType::PercentageIncrease => $currentAmount + (int) floor($currentAmount * $rule->adjustment_value / 10000),
        };
    }

    // ─── Step 7: Promotion/Coupon Validation ──────────────────────────────

    /**
     * Validate a coupon code against 7 checks.
     */
    public function validateCoupon(
        string $code,
        ?Participant $participant,
        int $cartTotal,
        Model $priceable,
        ?int $branchId = null
    ): ?Promotion {
        // Check 1: Exists, is active, and belongs to this branch.
        //
        // The branch is named explicitly rather than left to the Promotion
        // scope: this method runs from queues and console commands too, where
        // enforcement is off, and a coupon printed for one branch being honoured
        // at another branch's till is money.
        //
        // There is no academy-wide coupon any more: `branch_owns_the_catalogue`
        // moved `promotions` into the strict bucket and gave every existing row
        // a branch, so a null here is an unfiled row rather than a coupon good
        // everywhere, and honouring it at every till would be the leak this
        // check exists to close. Grouped so the branch test cannot be broken
        // apart by a later condition.
        $promotion = Promotion::where('code', $code)
            ->where('is_active', true)
            ->when($branchId, fn ($q) => $q->where(
                fn ($w) => $w->where('branch_id', $branchId)
            ))
            ->first();

        if (!$promotion) {
            return null;
        }

        $now = now()->toDateString();

        // Check 2: Start date
        if ($promotion->start_date && $now < $promotion->start_date->format('Y-m-d')) {
            return null;
        }

        // Check 3: End date
        if ($promotion->end_date && $now > $promotion->end_date->format('Y-m-d')) {
            return null;
        }

        // Check 4: Global usage limit
        if ($promotion->usage_limit !== null && $promotion->usage_count >= $promotion->usage_limit) {
            return null;
        }

        // Check 5: Per-participant usage limit
        if ($participant && $promotion->per_participant_limit !== null) {
            $participantUses = $promotion->usages()
                ->where('participant_id', $participant->id)
                ->count();
            if ($participantUses >= $promotion->per_participant_limit) {
                return null;
            }
        }

        // Check 6: Minimum purchase amount
        if ($promotion->min_purchase_amount !== null && $cartTotal < $promotion->min_purchase_amount) {
            return null;
        }

        // Check 7: Target match (if promotion is scoped to specific priceable)
        if ($promotion->target_type && $promotion->target_id) {
            if (
                $priceable->getMorphClass() !== $promotion->target_type
                || $priceable->getKey() != $promotion->target_id
            ) {
                return null;
            }
        }

        return $promotion;
    }

    private function applyPromotionAdjustment(int $currentAmount, int $originalAmount, Promotion $promotion): int
    {
        $adjustmentType = $promotion->adjustment_type;
        $value = $promotion->adjustment_value;

        return match ($adjustmentType) {
            AdjustmentType::PercentageDiscount => $currentAmount - (int) floor($currentAmount * $value / 10000),
            AdjustmentType::FixedDiscount => max(0, $currentAmount - $value),
            AdjustmentType::FixedPrice => $value,
            AdjustmentType::PercentageIncrease => $currentAmount + (int) floor($currentAmount * $value / 10000),
        };
    }

    // ─── Post-Calculation: Record Usage ───────────────────────────────────

    /**
     * Record promotion/coupon usage after a successful transaction.
     * Call this from POSService/InvoiceService AFTER committing the sale.
     */
    public function incrementUsage(
        Promotion $promotion,
        ?Participant $participant,
        ?int $invoiceId,
        int $discountAmount
    ): void {
        $promotion->increment('usage_count');

        $promotion->usages()->create([
            'academy_id' => $promotion->academy_id,
            'participant_id' => $participant?->id,
            'invoice_id' => $invoiceId,
            'discount_amount' => $discountAmount,
            'used_at' => now(),
        ]);
    }

    // ─── Step 8: Global Max Discount ──────────────────────────────────────

    private function getMaxDiscountPercent(): int
    {
        $value = (int) SystemSetting::get('pricing_max_discount_percent', self::DEFAULT_MAX_DISCOUNT_PERCENT);

        return ($value > 0 && $value <= 100) ? $value : self::DEFAULT_MAX_DISCOUNT_PERCENT;
    }

    /**
     * The most that may ever come off this amount, whoever assembled the total.
     *
     * Step 8 caps what the engine works out for itself. The discount picker
     * assembles a total of its own — the operator's selections, plus a manual
     * line — and it has to answer to the same academy ceiling, or the setting
     * governs only the discounts nobody chose. Public so the picker can ask.
     */
    public function maxDiscountFor(int $baseAmount): int
    {
        return (int) floor(max(0, $baseAmount) * $this->getMaxDiscountPercent() / 100);
    }

    // ─── Picker & Simulator ───────────────────────────────────────────────

    /**
     * Everything the discount picker and the rule simulator need.
     *
     * Unlike calculate(), this returns EVERY candidate the branch offers — including
     * the ones that do not qualify — each with a state and, when unavailable, a
     * human reason. A discount that silently fails to appear generates a phone call.
     *
     * @return array{result: PriceResult, candidates: DiscountCandidate[]}
     */
    public function explain(
        Model $priceable,
        ?Participant $participant = null,
        ?int $branchId = null,
        ?string $couponCode = null,
        ?string $date = null,
        array $extraContext = [],
        ?int $actorRoleLevel = null,
        ?array $contextOverride = null
    ): array {
        $date ??= now()->toDateString();
        $branchId ??= $this->resolveParticipantBranch($participant);

        $result = $this->calculate($priceable, $participant, $branchId, $couponCode, $date, $extraContext, $contextOverride);

        $appliedIds = collect($result->appliedRules)->pluck('rule_id')->all();

        if ($contextOverride !== null) {
            $context = array_merge($contextOverride, $extraContext);
        } elseif ($participant) {
            $context = array_merge($this->buildParticipantContext($participant), $extraContext);
        } else {
            $context = [];
        }

        $candidates = [];

        foreach ($this->gatherPickerRules($priceable, $branchId, $date) as $rule) {
            $candidates[] = $this->describeCandidate(
                $rule,
                $result,
                $appliedIds,
                $context,
                $participant !== null || $contextOverride !== null,
                $actorRoleLevel
            );
        }

        // Pinned first, then explicit order, then strongest discount.
        usort($candidates, function (DiscountCandidate $a, DiscountCandidate $b) {
            // pinned first, then display_order ASC, then strongest discount first
            return [$b->isPinned, $a->displayOrder, -$a->discountAmount]
                <=> [$a->isPinned, $b->displayOrder, -$b->discountAmount];
        });

        return ['result' => $result, 'candidates' => $candidates];
    }

    /**
     * Candidate rules for the picker: same branch/target/date filtering as the
     * engine, but WITHOUT dropping manual rules or exhausted ones — the picker
     * shows those with a reason instead of hiding them.
     */
    private function gatherPickerRules(Model $priceable, ?int $branchId, string $date): Collection
    {
        return PricingRule::where('is_active', true)
            ->where(function ($q) use ($date) {
                $q->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
            })
            ->where(function ($q) use ($date) {
                $q->whereNull('effective_to')->orWhere('effective_to', '>=', $date);
            })
            ->where(function ($q) use ($branchId) {
                $q->where(function ($sq) {
                    $sq->whereNull('branch_id')->whereNotExists(function ($e) {
                        $e->selectRaw('1')->from('pricing_rule_branches')
                            ->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id');
                    });
                });

                if ($branchId) {
                    $q->orWhere('branch_id', $branchId)
                        ->orWhereExists(function ($e) use ($branchId) {
                            $e->selectRaw('1')->from('pricing_rule_branches')
                                ->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id')
                                ->where('pricing_rule_branches.branch_id', $branchId);
                        });
                }
            })
            ->where(function ($q) use ($priceable) {
                $q->where(function ($sq) {
                    $sq->whereNull('target_type')->whereNull('target_id');
                })
                ->orWhere(function ($sq) use ($priceable) {
                    $sq->where('target_type', $priceable->getMorphClass())
                        ->where('target_id', $priceable->getKey());
                })
                ->orWhere(function ($sq) use ($priceable) {
                    $sq->where('target_type', $priceable->getMorphClass())->whereNull('target_id');
                });
            })
            ->orderByDesc('is_pinned')
            ->orderBy('display_order')
            ->get();
    }

    private function describeCandidate(
        PricingRule $rule,
        PriceResult $result,
        array $appliedIds,
        array $context,
        bool $hasParticipant,
        ?int $actorRoleLevel
    ): DiscountCandidate {
        $common = [
            'ruleId'       => $rule->id,
            'name'         => $rule->name_ar,
            'icon'         => $rule->pin_icon,
            'valueLabel'   => $this->adjustmentLabel($rule),
            'isStackable'  => (bool) $rule->is_stackable,
            'isPinned'     => (bool) $rule->is_pinned,
            'isManual'     => (bool) $rule->is_manual,
            'displayOrder' => (int) $rule->display_order,
        ];

        // Already applied by the engine.
        if (in_array($rule->id, $appliedIds, true)) {
            $line = collect($result->appliedRules)->firstWhere('rule_id', $rule->id);

            return new DiscountCandidate(
                ...$common,
                state: DiscountCandidate::APPLIED,
                discountAmount: (int) ($line['discount'] ?? 0),
            );
        }

        // Exhausted.
        if ($rule->usage_limit !== null && $rule->usage_count >= $rule->usage_limit) {
            return new DiscountCandidate(
                ...$common,
                state: DiscountCandidate::BLOCKED,
                reason: 'استُنفد الحد الأقصى للاستخدام',
            );
        }

        // Manual discounts: available only up to the actor's ceiling.
        if ($rule->is_manual) {
            $needsApproval = $rule->requires_approval
                || ($rule->min_role_level !== null && ($actorRoleLevel ?? 0) < $rule->min_role_level);

            return new DiscountCandidate(
                ...$common,
                state: $needsApproval ? DiscountCandidate::NEEDS_APPROVAL : DiscountCandidate::AVAILABLE,
                reason: $needsApproval ? 'يتجاوز صلاحيتك — يفتح طلب اعتماد' : null,
            );
        }

        if (! $hasParticipant) {
            return new DiscountCandidate(
                ...$common,
                state: DiscountCandidate::BLOCKED,
                reason: 'يتطلب اختيار مشترك',
            );
        }

        // Does the participant actually qualify?
        if (! $this->evaluateConditions($rule, $context)) {
            return new DiscountCandidate(
                ...$common,
                state: DiscountCandidate::BLOCKED,
                reason: 'المشترك غير مستوفٍ للشروط: ' . ConditionSchema::describe($rule->rule_type, $rule->conditions ?? []),
            );
        }

        // Qualifies but was not applied — it lost the non-stackable contest.
        return new DiscountCandidate(
            ...$common,
            state: $rule->is_stackable ? DiscountCandidate::STACKS : DiscountCandidate::AVAILABLE,
            discountAmount: $result->baseAmount - $this->applyAdjustment($result->baseAmount, $result->baseAmount, $rule),
            reason: $rule->is_stackable ? null : 'لا يُجمع — سيحل محل الخصم المطبق',
        );
    }

    private function adjustmentLabel(PricingRule $rule): string
    {
        return match ($rule->adjustment_type) {
            AdjustmentType::PercentageDiscount => '−' . Percent::label($rule->adjustment_value),
            AdjustmentType::PercentageIncrease => '+' . Percent::label($rule->adjustment_value),
            AdjustmentType::FixedDiscount      => '−' . number_format($rule->adjustment_value / 100, 2) . ' ج.م',
            AdjustmentType::FixedPrice         => number_format($rule->adjustment_value / 100, 2) . ' ج.م',
        };
    }

    /**
     * How many participants a rule would currently hit — shown live in the builder
     * so "this applies to 340 people" is caught before saving, not after.
     *
     * @return array{matched: int, total: int}
     */
    public function audience(PricingRule $rule, ?int $branchId = null): array
    {
        $query = Participant::query();
        if ($branchId) {
            $query->where('branch_id', $branchId);
        }

        $total = (clone $query)->count();

        if (empty($rule->conditions) && $rule->rule_type !== \App\Domain\Pricing\Enums\PricingRuleType::Custom) {
            return ['matched' => $total, 'total' => $total];
        }

        $matched = 0;
        $query->with(['person', 'guardians'])->chunkById(200, function ($chunk) use ($rule, &$matched) {
            foreach ($chunk as $participant) {
                if ($this->evaluateConditions($rule, $this->buildParticipantContext($participant))) {
                    $matched++;
                }
            }
        });

        return ['matched' => $matched, 'total' => $total];
    }

    /**
     * Does this (possibly unsaved) rule match this participant right now?
     *
     * Public so the builder can preview a draft before it is persisted.
     */
    public function wouldApply(PricingRule $rule, Participant $participant, array $extraContext = []): bool
    {
        $context = array_merge($this->buildParticipantContext($participant), $extraContext);

        return $this->evaluateConditions($rule, $context);
    }

    // ─── Governed Manual Discount ─────────────────────────────────────────

    /**
     * The discount ceiling for a role level, from system_settings.
     *
     * Replaces the super-admin free-text price override, which was the only
     * discount path that actually worked and therefore the one that got abused.
     */
    public function manualDiscountCap(?int $roleLevel): int
    {
        $level = $roleLevel ?? 0;

        if ($level >= 90) {
            return (int) SystemSetting::get('pricing_manual_cap_owner', 100);
        }
        if ($level >= 70) {
            return (int) SystemSetting::get('pricing_manual_cap_manager', 25);
        }

        return (int) SystemSetting::get('pricing_manual_cap_staff', 10);
    }

    /**
     * Validate a manual discount against the actor's ceiling.
     *
     * @return array{allowed: bool, requires_approval: bool, cap: int, message: ?string}
     */
    public function checkManualDiscount(int $baseAmount, int $discountAmount, ?int $roleLevel): array
    {
        $cap = $this->manualDiscountCap($roleLevel);

        if ($discountAmount <= 0 || $baseAmount <= 0) {
            return ['allowed' => false, 'requires_approval' => false, 'cap' => $cap,
                'message' => 'قيمة الخصم غير صالحة'];
        }

        if ($discountAmount > $baseAmount) {
            return ['allowed' => false, 'requires_approval' => false, 'cap' => $cap,
                'message' => 'الخصم أكبر من قيمة الصنف'];
        }

        $percent = (int) floor($discountAmount * 100 / $baseAmount);

        if ($percent > $cap) {
            return ['allowed' => false, 'requires_approval' => true, 'cap' => $cap,
                'message' => "الخصم {$percent}% يتجاوز صلاحيتك ({$cap}%) — يلزم اعتماد"];
        }

        return ['allowed' => true, 'requires_approval' => false, 'cap' => $cap, 'message' => null];
    }

    // ─── Helpers ──────────────────────────────────────────────────────────

    private function resolveParticipantBranch(?Participant $participant): ?int
    {
        if (!$participant) {
            return null;
        }

        // Participant may have branch via their primary enrollment group
        // or via person record — check what's available
        return $participant->getAttribute('branch_id')
            ?? $participant->person?->getAttribute('branch_id')
            ?? null;
    }
}
