<?php

declare(strict_types=1);

namespace App\Services\Fees;

use App\Enums\FeeType;
use App\Enums\ListingType;
use App\Enums\SellerTier;
use App\Enums\TransactionType;
use App\Models\Fee;
use App\Models\Plan;
use App\Models\Tld;
use App\Support\Money\Money;
use Illuminate\Support\Collection;

/**
 * Explains a fee schedule before it charges anybody.
 *
 * Composable rules are safer than competing ones, but they are not
 * self-explanatory: an administrator still cannot see at a glance what a
 * schedule of a dozen rules actually charges. This service answers the two
 * questions that matter.
 *
 *  - "What will this schedule charge?" -- {@see preview()} resolves a grid of
 *    representative contexts and reports the effective rate at each.
 *
 *  - "Is any rule I wrote dead?" -- {@see findUnreachableRules()} reports base
 *    rules that never win anywhere in that grid. A dead rule is almost always a
 *    mistake: either it was meant to be an adjustment, or something narrower is
 *    quietly shadowing it.
 *
 * The sampling grid is derived from the rules themselves -- every price-band
 * boundary and every scoped TLD, plan, listing type and tier that any rule
 * mentions -- so it probes exactly the boundaries the schedule cares about
 * rather than an arbitrary spread.
 */
final class FeeScheduleInspector
{
    public function __construct(
        private readonly FeeResolver $resolver,
    ) {}

    /**
     * Resolve the schedule across a representative grid.
     *
     * @return list<array{context: FeeContext, resolved: ResolvedFee|null, label: string}>
     */
    public function preview(FeeType $type, ?string $currency = null): array
    {
        $currency ??= Money::baseCurrency();
        $rules = $this->activeRules($type, $currency);

        /*
         * Resolve against the same snapshot the grid was built from.
         *
         * The injected resolver caches its rule set for the life of a request,
         * so reusing it here would compare a freshly-read schedule against a
         * stale one -- which is exactly wrong for the case this exists to serve:
         * an administrator saving a rule and previewing it in the same request.
         */
        $resolver = $this->resolver->usingRules($rules);

        $rows = [];

        foreach ($this->grid($rules, $currency) as $context) {
            $rows[] = [
                'context' => $context,
                'resolved' => $resolver->resolve($type, $context),
                'label' => $this->describe($context),
            ];
        }

        return $rows;
    }

    /**
     * Problems in the current schedule.
     *
     * Two distinct failure modes, because they have different causes and
     * different fixes.
     *
     * `unreachable` -- a base rule that never wins anywhere. Usually a typo in
     * its scope, or a rule that should have been an adjustment.
     *
     * `flattened_bands` -- the dangerous one. A base rule with no price band
     * outranks base rules that have them, so within that rule's scope the whole
     * volume-discount structure silently stops applying. This is what turned a
     * per-TLD rule an administrator read as a discount into a four-figure
     * overcharge on a large sale. It is deliberately reported even though the
     * banded rules are still reachable elsewhere: "reachable somewhere" is not
     * the same as "working where you meant it to".
     *
     * @return list<array{kind: string, rule: Fee, shadowed: list<Fee>, scope: string, reason: string}>
     */
    public function findProblems(FeeType $type, ?string $currency = null): array
    {
        $currency ??= Money::baseCurrency();
        $rules = $this->activeRules($type, $currency);
        $baseRules = $rules->filter(fn (Fee $rule): bool => $rule->isBase());

        if ($baseRules->count() < 2) {
            return [];
        }

        // Consistent snapshot, for the reason given in preview().
        $resolver = $this->resolver->usingRules($rules);

        $grid = $this->grid($rules, $currency);

        $winners = [];
        $matchedAnywhere = [];

        // Winner per non-price scope, keyed so price can be varied within it.
        $byScope = [];

        foreach ($grid as $context) {
            foreach ($baseRules as $rule) {
                if ($resolver->matches($rule, $context)) {
                    $matchedAnywhere[(int) $rule->getKey()] = true;
                }
            }

            $winner = $resolver->matchBaseRule($type, $context);

            if ($winner !== null) {
                $winners[(int) $winner->getKey()] = true;
            }

            $scopeKey = $this->scopeKey($context);
            $byScope[$scopeKey]['scope'] = $this->describeScope($context);
            $byScope[$scopeKey]['winners'][(int) ($winner?->getKey() ?? 0)] = $winner;
            $byScope[$scopeKey]['banded'] ??= [];

            foreach ($baseRules as $rule) {
                $isBanded = $rule->price_min_minor !== null || $rule->price_max_minor !== null;

                if ($isBanded && $resolver->matches($rule, $context)) {
                    $byScope[$scopeKey]['banded'][(int) $rule->getKey()] = $rule;
                }
            }
        }

        $problems = [];

        foreach ($baseRules as $rule) {
            if (isset($winners[(int) $rule->getKey()])) {
                continue;
            }

            $problems[] = [
                'kind' => 'unreachable',
                'rule' => $rule,
                'shadowed' => [],
                'scope' => 'everywhere',
                'reason' => isset($matchedAnywhere[(int) $rule->getKey()])
                    ? 'This rule never applies: a more specific base rule always wins. If it was meant to modify the rate rather than replace it, make it an adjustment.'
                    : 'This rule matches nothing. Check its currency, price band and scope columns.',
            ];
        }

        /*
         * Flattening is detected per scope, but reported per rule. A single bad
         * rule can flatten bands across dozens of scope combinations, and
         * printing the same problem forty times buries it rather than surfacing
         * it. Findings are merged by (offending rule, suppressed rules) and the
         * affected scopes are summarised.
         */
        $flattening = [];

        foreach ($byScope as $entry) {
            /** @var array<int, Fee|null> $scopeWinners */
            $scopeWinners = array_filter($entry['winners'] ?? []);

            // Banding is only flattened if exactly one rule wins across every
            // price point in this scope...
            if (count($scopeWinners) !== 1) {
                continue;
            }

            $winner = reset($scopeWinners);

            // ...that rule itself carries no band...
            if ($winner->price_min_minor !== null || $winner->price_max_minor !== null) {
                continue;
            }

            // ...and banded rules did match here but lost.
            $shadowed = array_values(array_filter(
                $entry['banded'] ?? [],
                fn (Fee $banded): bool => $banded->getKey() !== $winner->getKey(),
            ));

            if ($shadowed === []) {
                continue;
            }

            $shadowedCodes = array_map(fn (Fee $r): string => (string) $r->code, $shadowed);
            sort($shadowedCodes);
            $key = $winner->getKey().'::'.implode(',', $shadowedCodes);

            $flattening[$key]['rule'] = $winner;
            $flattening[$key]['shadowed'] = $shadowed;
            $flattening[$key]['scopes'][$entry['scope']] = true;
        }

        foreach ($flattening as $entry) {
            $scopes = array_keys($entry['scopes']);
            sort($scopes);

            $problems[] = [
                'kind' => 'flattened_bands',
                'rule' => $entry['rule'],
                'shadowed' => $entry['shadowed'],
                'scope' => $this->summariseScopes($scopes),
                'reason' => sprintf(
                    'This rule outranks %d banded rule(s), so within its scope the price bands stop applying and every sale is charged one flat rate. If you meant to shift the rate while keeping the bands, make this an adjustment instead.',
                    count($entry['shadowed']),
                ),
            ];
        }

        return $problems;
    }

    /**
     * Base rules that never win anywhere.
     *
     * Kept as a narrow accessor over {@see findProblems()}.
     *
     * @return list<array{rule: Fee, reason: string}>
     */
    public function findUnreachableRules(FeeType $type, ?string $currency = null): array
    {
        return array_values(array_map(
            fn (array $problem): array => ['rule' => $problem['rule'], 'reason' => $problem['reason']],
            array_filter(
                $this->findProblems($type, $currency),
                fn (array $problem): bool => $problem['kind'] === 'unreachable',
            ),
        ));
    }

    /**
     * The effect of adding or changing one rule, expressed as before and after.
     *
     * This is what an admin surface must show before saving a rate change --
     * the absence of exactly this view is what allowed a fee edit to overcharge
     * silently. Nothing is written: the proposed rule is spliced into an
     * in-memory copy of the schedule.
     *
     * @param  Fee  $proposed  May be unsaved. If it carries a key, it replaces
     *                         the stored rule with that key rather than adding.
     * @return list<array{label: string, before: ResolvedFee|null, after: ResolvedFee|null, changed: bool}>
     */
    public function previewChange(FeeType $type, Fee $proposed, ?string $currency = null): array
    {
        $currency ??= Money::baseCurrency();

        $current = $this->activeRules($type, $currency);

        // Replace by key when the rule already exists, otherwise append.
        $proposedKey = $proposed->getKey();
        $withProposal = $current
            ->reject(fn (Fee $rule): bool => $proposedKey !== null && $rule->getKey() === $proposedKey)
            ->push($proposed)
            ->values();

        $before = $this->resolver->usingRules($current);
        $after = $this->resolver->usingRules($withProposal);

        $rows = [];

        foreach ($this->grid($withProposal, $currency) as $context) {
            $b = $before->resolve($type, $context);
            $a = $after->resolve($type, $context);

            $rows[] = [
                'label' => $this->describe($context),
                'before' => $b,
                'after' => $a,
                'changed' => ($b?->amount->minor ?? null) !== ($a?->amount->minor ?? null),
            ];
        }

        return $rows;
    }

    /**
     * @return Collection<int, Fee>
     */
    private function activeRules(FeeType $type, string $currency): Collection
    {
        return Fee::query()
            ->active()
            ->where('type', $type)
            ->where('currency', $currency)
            ->get();
    }

    /**
     * Build the sampling grid from the rules' own boundaries.
     *
     * Price points come from every band edge plus one minor unit either side,
     * which is where off-by-one band errors live. Scope values come from
     * whatever the rules actually reference, plus the unscoped case.
     *
     * @param  Collection<int, Fee>  $rules
     * @return list<FeeContext>
     */
    private function grid(Collection $rules, string $currency): array
    {
        $prices = $this->pricePoints($rules, $currency);

        $tldIds = $rules->pluck('tld_id')->filter()->unique()->values();
        $tlds = $tldIds->isEmpty()
            ? [null]
            : array_merge([null], Tld::query()->whereKey($tldIds)->get()->all());

        $planIds = $rules->pluck('plan_id')->filter()->unique()->values();
        $plans = $planIds->isEmpty()
            ? [null]
            : array_merge([null], Plan::query()->whereKey($planIds)->get()->all());

        $listingTypes = $rules->pluck('listing_type')->filter()->unique()->values()->all();
        $listingTypes = $listingTypes === [] ? [ListingType::FixedPrice] : array_merge([ListingType::FixedPrice], $listingTypes);

        $tiers = $rules->pluck('seller_tier')->filter()->unique()->values()->all();
        $tiers = $tiers === [] ? [SellerTier::Standard] : array_merge([SellerTier::Standard], $tiers);

        $transactionTypes = $rules->pluck('transaction_type')->filter()->unique()->values()->all();
        $transactionTypes = $transactionTypes === []
            ? [TransactionType::MarketplaceSale]
            : array_merge([TransactionType::MarketplaceSale], $transactionTypes);

        $contexts = [];

        foreach ($prices as $price) {
            foreach ($tlds as $tld) {
                foreach ($plans as $plan) {
                    foreach (array_unique($listingTypes, SORT_REGULAR) as $listingType) {
                        foreach (array_unique($tiers, SORT_REGULAR) as $tier) {
                            foreach (array_unique($transactionTypes, SORT_REGULAR) as $transactionType) {
                                $contexts[] = new FeeContext(
                                    amount: Money::of($price, $currency),
                                    tld: $tld,
                                    plan: $plan,
                                    listingType: $listingType,
                                    transactionType: $transactionType,
                                    sellerTier: $tier,
                                );
                            }
                        }
                    }
                }
            }
        }

        return $contexts;
    }

    /**
     * @param  Collection<int, Fee>  $rules
     * @return list<int>
     */
    private function pricePoints(Collection $rules, string $currency): array
    {
        $exponent = Money::exponentFor($currency);
        $unit = 10 ** $exponent;

        // A spread of realistic sale prices, so a schedule with no bands at all
        // is still probed across the range the marketplace actually sees.
        $points = [
            100 * $unit,
            2_500 * $unit,
            25_000 * $unit,
            250_000 * $unit,
        ];

        foreach ($rules as $rule) {
            foreach ([$rule->price_min_minor, $rule->price_max_minor] as $edge) {
                if ($edge === null) {
                    continue;
                }

                $edge = (int) $edge;

                // The edge itself and one unit either side: band boundaries are
                // where inclusive/exclusive mistakes hide.
                foreach ([$edge - 1, $edge, $edge + 1] as $point) {
                    if ($point > 0) {
                        $points[] = $point;
                    }
                }
            }
        }

        $points = array_values(array_unique($points));
        sort($points);

        return $points;
    }

    /**
     * Condense a list of affected scopes into something readable.
     *
     * @param  list<string>  $scopes
     */
    private function summariseScopes(array $scopes): string
    {
        if (count($scopes) <= 3) {
            return implode('; ', $scopes);
        }

        return sprintf(
            '%s, and %d further scope combination(s)',
            $scopes[0],
            count($scopes) - 1,
        );
    }

    /** Identity of a context ignoring its amount, so price can be varied within it. */
    private function scopeKey(FeeContext $context): string
    {
        return implode('|', [
            $context->tld?->getKey() ?? '-',
            $context->plan?->getKey() ?? '-',
            $context->listingType?->value ?? '-',
            $context->transactionType?->value ?? '-',
            $context->sellerTier?->value ?? '-',
        ]);
    }

    private function describeScope(FeeContext $context): string
    {
        $parts = [];

        if ($context->tld !== null) {
            $parts[] = '.'.$context->tld->tld;
        }
        if ($context->plan !== null) {
            $parts[] = 'plan '.$context->plan->code;
        }
        if ($context->listingType !== null) {
            $parts[] = $context->listingType->value.' listings';
        }
        if ($context->sellerTier !== null) {
            $parts[] = $context->sellerTier->value.' sellers';
        }

        return $parts === [] ? 'all sales' : implode(', ', $parts);
    }

    private function describe(FeeContext $context): string
    {
        $parts = [$context->amount->toMajorString()];

        $parts[] = $context->tld === null ? 'any TLD' : '.'.$context->tld->tld;
        $parts[] = $context->plan === null ? 'no plan' : 'plan:'.$context->plan->code;
        $parts[] = $context->listingType?->value ?? 'any listing';
        $parts[] = $context->sellerTier?->value ?? 'any tier';

        return implode(' | ', $parts);
    }
}
