<?php

declare(strict_types=1);

namespace App\Services\Fees;

use App\Enums\FeeRole;
use App\Enums\FeeType;
use App\Models\Fee;
use App\Support\Money\Money;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;

/**
 * Resolves which configurable fee rules apply, then computes the charge.
 *
 * Resolution is two-stage, and the split is the whole point of the design.
 *
 *  1. One BASE rule sets the rate. Among active base rules whose scope matches
 *     the context, the most specific wins, with `priority` breaking ties.
 *
 *  2. Every matching ADJUSTMENT then shifts that figure. Adjustments compose;
 *     they do not compete.
 *
 * The earlier single-winner design ranked scope dimensions against each other,
 * which meant a narrowly scoped rule silently cancelled every broader one. In
 * practice that let an administrator add a per-TLD commission rule they read as
 * a small discount and, without warning, remove a volume discount worth
 * thousands on a large sale. Nothing logged, because a rule *had* matched.
 *
 * Two properties are worth preserving in any future change:
 *
 *  - Adjustments are computed against the ORIGINAL subject amount, never
 *    against the running total. Composition is therefore commutative: the order
 *    rules were created in cannot change the answer.
 *
 *  - The base rule's floor and ceiling are applied AFTER adjustments, so a cap
 *    bounds the fee actually charged rather than an intermediate figure.
 *
 * When no base rule matches at all, the configured fallback rate applies and
 * the miss is logged. Charging nothing silently would be worse: it would let a
 * misconfigured environment give away the platform's revenue unnoticed.
 */
final class FeeResolver
{
    /** @var array<string, Collection<int, Fee>> */
    private array $ruleCache = [];

    /**
     * When set, resolution reads from this rule set instead of the database.
     *
     * Exists so a proposed-but-unsaved rule can be evaluated against the live
     * schedule -- which is what lets an administrator see the effect of a change
     * before committing to it. Previewing inside a rolled-back transaction would
     * also work, but it makes a read-only operation take write locks.
     *
     * @var Collection<int, Fee>|null
     */
    private ?Collection $ruleOverride = null;

    /**
     * Resolve one fee type, or null when the type genuinely does not apply
     * (for example a buyer fee an administrator has not configured).
     */
    public function resolve(FeeType $type, FeeContext $context): ?ResolvedFee
    {
        $base = $this->matchBaseRule($type, $context);

        if ($base === null) {
            return $this->fallbackFor($type, $context);
        }

        $applied = [AppliedFeeRule::from($base)];
        $amount = $base->contributionOn($context->amount);

        foreach ($this->matchAdjustments($type, $context) as $adjustment) {
            $delta = $adjustment->contributionOn($context->amount);

            if ($delta->isZero()) {
                continue;
            }

            $amount = $amount->plus($delta);
            $applied[] = AppliedFeeRule::from($adjustment, $delta);
        }

        // The base rule's bounds apply to the final figure, after adjustments.
        $amount = $base->clampToBounds($amount, $context->amount->currency);

        /*
         * A fee may be reduced to nothing by adjustments, but it must never
         * invert into a payment to the counterparty. An administrator who wants
         * to pay a seller more than the sale price has to do that through a
         * credit, not by over-discounting a commission.
         */
        if ($amount->isNegative()) {
            Log::channel((string) config('logging.audit_channel', 'audit'))->warning('fee.adjustments_exceeded_base', [
                'fee_type' => $type->value,
                'base_rule' => $base->code,
                'computed_minor' => $amount->minor,
                'currency' => $amount->currency,
            ]);

            $amount = Money::zero($context->amount->currency);
        }

        return ResolvedFee::fromRules($type, $base, $amount, $applied);
    }

    /**
     * Resolve several fee types at once, dropping those that compute to zero.
     *
     * @param  list<FeeType>  $types
     * @return list<ResolvedFee>
     */
    public function resolveMany(array $types, FeeContext $context): array
    {
        $resolved = [];

        foreach ($types as $type) {
            $fee = $this->resolve($type, $context);

            if ($fee !== null && ! $fee->amount->isZero()) {
                $resolved[] = $fee;
            }
        }

        return $resolved;
    }

    /**
     * The single best-matching base rule, or null if none apply.
     */
    public function matchBaseRule(FeeType $type, FeeContext $context): ?Fee
    {
        $candidates = $this->rulesOfType($type)
            ->filter(fn (Fee $rule): bool => $rule->isBase() && $this->matches($rule, $context));

        if ($candidates->isEmpty()) {
            return null;
        }

        /*
         * Most specific wins; `priority` breaks ties between rules scoped
         * equally narrowly; the newest rule breaks a remaining tie so a freshly
         * added override takes effect predictably.
         */
        return $candidates
            ->sortByDesc(fn (Fee $rule): array => [
                $rule->specificity(),
                (int) $rule->priority,
                (int) $rule->getKey(),
            ])
            ->first();
    }

    /**
     * Every adjustment rule that applies, in a deterministic order.
     *
     * Order does not affect the result -- deltas are computed against the
     * original amount -- but a stable order keeps the audit snapshot
     * reproducible.
     *
     * @return Collection<int, Fee>
     */
    public function matchAdjustments(FeeType $type, FeeContext $context): Collection
    {
        return $this->rulesOfType($type)
            ->filter(fn (Fee $rule): bool => $rule->isAdjustment() && $this->matches($rule, $context))
            ->sortBy(fn (Fee $rule): array => [(int) $rule->priority, (int) $rule->getKey()])
            ->values();
    }

    /** Whether a rule's scope admits the given context. */
    public function matches(Fee $rule, FeeContext $context): bool
    {
        // Currency must line up or the computed fee would be meaningless.
        if (strtoupper((string) $rule->currency) !== $context->amount->currency) {
            return false;
        }

        if (! $rule->coversAmount($context->amount)) {
            return false;
        }

        if ($rule->tld_id !== null && (int) $rule->tld_id !== $context->tld?->getKey()) {
            return false;
        }

        if ($rule->plan_id !== null && (int) $rule->plan_id !== $context->plan?->getKey()) {
            return false;
        }

        if ($rule->listing_type !== null && $rule->listing_type !== $context->listingType) {
            return false;
        }

        if ($rule->transaction_type !== null && $rule->transaction_type !== $context->transactionType) {
            return false;
        }

        if ($rule->seller_tier !== null && $rule->seller_tier !== $context->sellerTier) {
            return false;
        }

        return true;
    }

    /**
     * Last-resort rate from configuration.
     *
     * Only commission and buyer fees have a config fallback -- the rest are
     * genuinely optional charges, and inventing one an administrator never
     * configured would be worse than charging nothing.
     */
    private function fallbackFor(FeeType $type, FeeContext $context): ?ResolvedFee
    {
        $bps = match ($type) {
            FeeType::SellerCommission => (int) config('marketplace.fees.default_seller_commission_bps'),
            FeeType::BuyerFee => (int) config('marketplace.fees.default_buyer_fee_bps'),
            default => null,
        };

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

        Log::channel((string) config('logging.audit_channel', 'audit'))->warning('fee.rule_missing', [
            'fee_type' => $type->value,
            'fallback_bps' => $bps,
            'amount_minor' => $context->amount->minor,
            'currency' => $context->amount->currency,
            'tld' => $context->tld?->tld,
            'listing_type' => $context->listingType?->value,
        ]);

        return new ResolvedFee(
            type: $type,
            code: 'fallback_'.$type->value,
            label: $type->label(),
            amount: $context->amount->percentageBps($bps),
            rateBps: $bps,
            isFallback: true,
        );
    }

    /**
     * Active rules of a type, loaded once per instance.
     *
     * The table is small and read on every checkout, so a per-request cache
     * avoids repeating the query for each fee in a single quote. Both roles are
     * loaded together because a quote needs both.
     *
     * @return Collection<int, Fee>
     */
    private function rulesOfType(FeeType $type): Collection
    {
        if ($this->ruleOverride !== null) {
            return $this->ruleOverride->filter(fn (Fee $rule): bool => $rule->type === $type)->values();
        }

        return $this->ruleCache[$type->value] ??= Fee::query()
            ->active()
            ->where('type', $type)
            ->get();
    }

    /** Drop the per-request cache; used by tests and by admin fee edits. */
    public function flush(): void
    {
        $this->ruleCache = [];
    }

    /**
     * A resolver that reads from the given rules rather than the database.
     *
     * @param  Collection<int, Fee>  $rules
     */
    public function usingRules(Collection $rules): self
    {
        $clone = new self;
        $clone->ruleOverride = $rules;

        return $clone;
    }

    /**
     * Validate a rule's shape against its role.
     *
     * Called before persisting a rule, from the admin surface and the seeder.
     * The schema cannot express "a base rate may not be negative" because the
     * same column serves both roles, so it is enforced here.
     *
     * @return list<string> Human-readable problems; empty when the rule is sound.
     */
    public function validateRule(Fee $rule): array
    {
        $problems = [];
        $bps = $rule->rate_bps === null ? null : (int) $rule->rate_bps;

        if ($rule->role === FeeRole::Base && $bps !== null && $bps < 0) {
            $problems[] = 'A base rate cannot be negative. Use an adjustment rule to reduce a rate.';
        }

        if ($rule->role === FeeRole::Adjustment) {
            if ($bps === null && $rule->fixed_amount_minor === null) {
                $problems[] = 'An adjustment must change something: set a rate delta or a fixed amount.';
            }

            if ($rule->min_fee_minor !== null || $rule->max_fee_minor !== null) {
                $problems[] = 'Floors and ceilings belong on the base rule; they are applied to the final fee.';
            }
        }

        if ($rule->calculation->usesRate() && $bps === null) {
            $problems[] = 'This calculation needs a rate in basis points.';
        }

        if ($rule->calculation->usesFixed() && $rule->fixed_amount_minor === null) {
            $problems[] = 'This calculation needs a fixed amount.';
        }

        if ($rule->price_min_minor !== null && $rule->price_max_minor !== null
            && (int) $rule->price_min_minor > (int) $rule->price_max_minor) {
            $problems[] = 'The price band is inverted: its minimum exceeds its maximum.';
        }

        return $problems;
    }
}
