<?php

declare(strict_types=1);

namespace Tests\Feature\Marketplace;

use App\Enums\FeeRole;
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\Services\Fees\FeeContext;
use App\Services\Fees\FeeResolver;
use App\Services\Fees\FeeScheduleInspector;
use App\Support\Money\Money;
use Database\Seeders\FeeSeeder;
use Database\Seeders\PlanSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class FeeResolverTest extends TestCase
{
    use RefreshDatabase;

    private function resolver(): FeeResolver
    {
        // A fresh instance per assertion: the resolver caches rules per request.
        return new FeeResolver;
    }

    private function context(string $amount, array $overrides = []): FeeContext
    {
        return new FeeContext(
            amount: Money::fromMajorString($amount, 'USD'),
            tld: $overrides['tld'] ?? null,
            plan: $overrides['plan'] ?? null,
            listingType: $overrides['listingType'] ?? ListingType::FixedPrice,
            transactionType: $overrides['transactionType'] ?? TransactionType::MarketplaceSale,
            sellerTier: $overrides['sellerTier'] ?? SellerTier::Standard,
        );
    }

    #[Test]
    public function it_applies_a_percentage_rule(): void
    {
        Fee::factory()->percentage(1000)->create(['code' => 'commission_default']);

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('12500.00'));

        $this->assertNotNull($fee);
        $this->assertSame('1250.00', $fee->amount->toMajorString());
        $this->assertSame(1000, $fee->rateBps);
        $this->assertFalse($fee->isFallback);
    }

    #[Test]
    public function it_applies_a_percentage_plus_fixed_rule(): void
    {
        Fee::factory()
            ->ofType(FeeType::BuyerFee)
            ->percentagePlusFixed(290, 30)
            ->create();

        $fee = $this->resolver()->resolve(FeeType::BuyerFee, $this->context('100.00'));

        // 2.9% of $100.00 is $2.90, plus $0.30 = $3.20.
        $this->assertSame('3.20', $fee->amount->toMajorString());
    }

    #[Test]
    public function it_honours_a_fee_cap(): void
    {
        Fee::factory()
            ->ofType(FeeType::BuyerFee)
            ->percentage(290)
            ->capped(null, 250_000)
            ->create();

        // Uncapped this would be $7,250; the cap holds it at $2,500.
        $fee = $this->resolver()->resolve(FeeType::BuyerFee, $this->context('250000.00'));

        $this->assertSame('2500.00', $fee->amount->toMajorString());
    }

    #[Test]
    public function it_honours_a_fee_floor(): void
    {
        Fee::factory()->percentage(1000)->capped(500, null)->create();

        // 10% of $10.00 is $1.00, but the floor lifts it to $5.00.
        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('10.00'));

        $this->assertSame('5.00', $fee->amount->toMajorString());
    }

    /**
     * The core of the design: a narrowly scoped rule must beat a broad one, so
     * an administrator can add a `.ai` override without touching the default.
     */
    #[Test]
    public function a_more_specific_rule_wins_over_a_general_one(): void
    {
        $tld = Tld::factory()->create(['tld' => 'ai']);

        Fee::factory()->percentage(1000)->create(['code' => 'general']);
        Fee::factory()->percentage(500)->create(['code' => 'ai_only', 'tld_id' => $tld->getKey()]);

        $general = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));
        $specific = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00', ['tld' => $tld]));

        $this->assertSame('100.00', $general->amount->toMajorString());
        $this->assertSame('50.00', $specific->amount->toMajorString());
    }

    #[Test]
    public function a_price_band_rule_applies_only_within_its_band(): void
    {
        Fee::factory()->percentage(1000)->create(['code' => 'default']);
        Fee::factory()->percentage(600)->priceBand(1_000_000, null)->create(['code' => 'high_value']);

        $below = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('5000.00'));
        $inside = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('50000.00'));

        $this->assertSame('500.00', $below->amount->toMajorString());
        $this->assertSame('3000.00', $inside->amount->toMajorString());
    }

    #[Test]
    public function a_plan_scoped_rule_applies_only_to_that_plan(): void
    {
        $plan = Plan::factory()->create();

        Fee::factory()->percentage(1000)->create(['code' => 'default']);
        Fee::factory()->percentage(500)->create(['code' => 'plan_rate', 'plan_id' => $plan->getKey()]);

        $without = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));
        $with = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00', ['plan' => $plan]));

        $this->assertSame('100.00', $without->amount->toMajorString());
        $this->assertSame('50.00', $with->amount->toMajorString());
    }

    #[Test]
    public function a_seller_tier_rule_applies_only_to_that_tier(): void
    {
        Fee::factory()->percentage(1000)->create(['code' => 'default']);
        Fee::factory()->percentage(500)->create([
            'code' => 'enterprise',
            'seller_tier' => SellerTier::Enterprise,
        ]);

        $standard = $this->resolver()->resolve(
            FeeType::SellerCommission,
            $this->context('1000.00', ['sellerTier' => SellerTier::Standard])
        );
        $enterprise = $this->resolver()->resolve(
            FeeType::SellerCommission,
            $this->context('1000.00', ['sellerTier' => SellerTier::Enterprise])
        );

        $this->assertSame('100.00', $standard->amount->toMajorString());
        $this->assertSame('50.00', $enterprise->amount->toMajorString());
    }

    #[Test]
    public function a_listing_type_rule_applies_only_to_that_listing_type(): void
    {
        Fee::factory()->percentage(1000)->create(['code' => 'default']);
        Fee::factory()->percentage(1200)->create([
            'code' => 'auction',
            'listing_type' => ListingType::Auction,
        ]);

        $fixed = $this->resolver()->resolve(
            FeeType::SellerCommission,
            $this->context('1000.00', ['listingType' => ListingType::FixedPrice])
        );
        $auction = $this->resolver()->resolve(
            FeeType::SellerCommission,
            $this->context('1000.00', ['listingType' => ListingType::Auction])
        );

        $this->assertSame('100.00', $fixed->amount->toMajorString());
        $this->assertSame('120.00', $auction->amount->toMajorString());
    }

    #[Test]
    public function it_ignores_inactive_rules(): void
    {
        Fee::factory()->percentage(500)->inactive()->create();
        Fee::factory()->percentage(1000)->create();

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));

        $this->assertSame('100.00', $fee->amount->toMajorString());
    }

    /**
     * Fee history matters for reconciliation: a rule whose window has closed
     * must stop applying, and one scheduled for later must not apply yet.
     */
    #[Test]
    public function it_ignores_rules_outside_their_effective_window(): void
    {
        Fee::factory()->percentage(500)->expired()->create(['code' => 'old_rate']);
        Fee::factory()->percentage(2000)->future()->create(['code' => 'future_rate']);
        Fee::factory()->percentage(1000)->create(['code' => 'current_rate']);

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));

        $this->assertSame('current_rate', $fee->code);
        $this->assertSame('100.00', $fee->amount->toMajorString());
    }

    #[Test]
    public function it_ignores_rules_in_another_currency(): void
    {
        Fee::factory()->percentage(500)->create(['currency' => 'EUR']);
        Fee::factory()->percentage(1000)->create(['currency' => 'USD']);

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));

        $this->assertSame('100.00', $fee->amount->toMajorString());
    }

    /**
     * A misconfigured platform must not silently give its revenue away, so a
     * missing commission rule falls back to config and shouts about it.
     */
    #[Test]
    public function a_missing_commission_rule_falls_back_and_is_logged(): void
    {
        Log::shouldReceive('channel')->andReturnSelf();
        Log::shouldReceive('warning')
            ->once()
            ->withSomeOfArgs('fee.rule_missing');

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));

        $this->assertNotNull($fee);
        $this->assertTrue($fee->isFallback);
        // The configured default of 1000 bps.
        $this->assertSame('100.00', $fee->amount->toMajorString());
    }

    #[Test]
    public function an_optional_fee_with_no_rule_resolves_to_nothing(): void
    {
        // No transfer fee configured means no transfer fee charged -- inventing
        // one an administrator never set up would be worse.
        $this->assertNull(
            $this->resolver()->resolve(FeeType::TransferFee, $this->context('1000.00'))
        );
    }

    #[Test]
    public function resolve_many_drops_fees_that_compute_to_zero(): void
    {
        Fee::factory()->percentage(1000)->create();
        Fee::factory()->ofType(FeeType::BuyerFee)->percentage(0)->create();
        Fee::factory()->ofType(FeeType::TransferFee)->fixed(1500)->create();

        $fees = $this->resolver()->resolveMany([
            FeeType::SellerCommission,
            FeeType::BuyerFee,
            FeeType::TransferFee,
        ], $this->context('1000.00'));

        $codes = array_map(fn ($fee): string => $fee->type->value, $fees);

        $this->assertContains('seller_commission', $codes);
        $this->assertContains('transfer_fee', $codes);
        $this->assertNotContains('buyer_fee', $codes);
    }

    /**
     * A resolved fee is persisted with an order as a snapshot, so a later change
     * to the rule cannot rewrite what a historical sale charged.
     */
    #[Test]
    public function a_resolved_fee_snapshots_the_rule_that_produced_it(): void
    {
        $rule = Fee::factory()->percentage(750)->create();

        $snapshot = $this->resolver()
            ->resolve(FeeType::SellerCommission, $this->context('1000.00'))
            ->toSnapshot();

        $this->assertSame($rule->getKey(), $snapshot['rule_id']);
        $this->assertSame(750, $snapshot['rate_bps']);
        $this->assertSame(7500, $snapshot['amount_minor']);
        $this->assertSame('USD', $snapshot['currency']);
    }

    // -----------------------------------------------------------------
    // Composition. These are the tests whose absence allowed a per-TLD rule
    // to silently cancel volume discounts and overcharge a seller by $7,500
    // on a $250,000 sale. Every one of them puts two scope dimensions in
    // tension -- which the original suite never did.
    // -----------------------------------------------------------------

    /**
     * The regression test for the defect, stated as the scenario that caused it.
     */
    #[Test]
    public function a_tld_adjustment_does_not_cancel_the_price_bands(): void
    {
        $tld = Tld::factory()->create(['tld' => 'com']);

        Fee::factory()->percentage(1000)->create(['code' => 'band_default']);
        Fee::factory()->percentage(600)->priceBand(10_000_001, null)->create(['code' => 'band_high']);

        // The administrator wants one point off for .com. Under the previous
        // single-winner model this silently replaced the band.
        Fee::factory()->adjustment(-100)->create(['code' => 'adj_com', 'tld_id' => $tld->getKey()]);

        $resolver = $this->resolver();

        // Small sale: default band 10%, less one point.
        $this->assertSame('225.00', $resolver
            ->resolve(FeeType::SellerCommission, $this->context('2500.00', ['tld' => $tld]))
            ->amount->toMajorString());

        // Large sale: the high band still applies at 6%, less one point = 5%.
        $this->assertSame('12500.00', $resolver
            ->resolve(FeeType::SellerCommission, $this->context('250000.00', ['tld' => $tld]))
            ->amount->toMajorString());
    }

    #[Test]
    public function every_matching_adjustment_applies(): void
    {
        $tld = Tld::factory()->create(['tld' => 'com']);
        $plan = Plan::factory()->create();

        Fee::factory()->percentage(1000)->create();
        Fee::factory()->adjustment(-100)->create(['tld_id' => $tld->getKey()]);
        Fee::factory()->adjustment(-250)->create(['plan_id' => $plan->getKey()]);
        Fee::factory()->adjustment(-100)->create(['seller_tier' => SellerTier::Enterprise]);

        // 10% - 1% - 2.5% - 1% = 5.5% of $10,000 = $550.
        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('10000.00', [
            'tld' => $tld,
            'plan' => $plan,
            'sellerTier' => SellerTier::Enterprise,
        ]));

        $this->assertSame('550.00', $fee->amount->toMajorString());
        $this->assertSame(3, $fee->adjustmentCount());
    }

    /**
     * Deltas are computed against the original amount, never the running total,
     * so an administrator cannot get a different answer by creating the same
     * rules in a different order.
     */
    #[Test]
    public function adjustment_order_cannot_change_the_result(): void
    {
        $tld = Tld::factory()->create(['tld' => 'com']);

        Fee::factory()->percentage(1000)->create();
        Fee::factory()->adjustment(-200)->create(['tld_id' => $tld->getKey(), 'priority' => 50]);
        Fee::factory()->adjustment(-300)->create(['tld_id' => $tld->getKey(), 'priority' => 1]);

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00', ['tld' => $tld]));

        // 10% - 2% - 3% = 5%, whichever order they are applied in.
        $this->assertSame('50.00', $fee->amount->toMajorString());
    }

    #[Test]
    public function an_adjustment_can_increase_a_fee(): void
    {
        Fee::factory()->percentage(1000)->create();
        Fee::factory()->adjustment(200)->create(['listing_type' => ListingType::Auction]);

        $fixed = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00', [
            'listingType' => ListingType::FixedPrice,
        ]));
        $auction = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00', [
            'listingType' => ListingType::Auction,
        ]));

        $this->assertSame('100.00', $fixed->amount->toMajorString());
        $this->assertSame('120.00', $auction->amount->toMajorString());
    }

    #[Test]
    public function a_fixed_adjustment_shifts_the_amount(): void
    {
        Fee::factory()->percentage(1000)->create();
        Fee::factory()->fixedAdjustment(-500)->create();

        // 10% of $100 is $10.00, less a $5.00 flat credit.
        $this->assertSame('5.00', $this->resolver()
            ->resolve(FeeType::SellerCommission, $this->context('100.00'))
            ->amount->toMajorString());
    }

    /**
     * A cap must bound what is actually charged, not an intermediate figure, so
     * it is applied after adjustments rather than before.
     */
    #[Test]
    public function the_base_cap_applies_after_adjustments(): void
    {
        Fee::factory()->percentage(1000)->capped(null, 50_000)->create();
        Fee::factory()->adjustment(-500)->create();

        // 10% of $10,000 = $1,000, less 5% = $500. The $500 cap binds exactly.
        $this->assertSame('500.00', $this->resolver()
            ->resolve(FeeType::SellerCommission, $this->context('10000.00'))
            ->amount->toMajorString());
    }

    /**
     * Over-discounting must floor at zero rather than inverting into a payment
     * to the seller.
     */
    #[Test]
    public function adjustments_cannot_drive_a_fee_negative(): void
    {
        Log::shouldReceive('channel')->andReturnSelf();
        Log::shouldReceive('warning')->withSomeOfArgs('fee.adjustments_exceeded_base')->atLeast()->once();

        Fee::factory()->percentage(500)->create();
        Fee::factory()->adjustment(-2000)->create();

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));

        $this->assertTrue($fee->amount->isZero());
        $this->assertFalse($fee->amount->isNegative());
    }

    #[Test]
    public function an_adjustment_never_acts_as_a_base_rate(): void
    {
        // An adjustment alone leaves no base rule, so the configured fallback
        // applies rather than the adjustment being treated as a rate.
        Fee::factory()->adjustment(-100)->create();

        Log::shouldReceive('channel')->andReturnSelf();
        Log::shouldReceive('warning')->withSomeOfArgs('fee.rule_missing')->atLeast()->once();

        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $this->context('1000.00'));

        $this->assertTrue($fee->isFallback);
    }

    #[Test]
    public function the_snapshot_records_the_whole_derivation(): void
    {
        $tld = Tld::factory()->create(['tld' => 'com']);

        $base = Fee::factory()->percentage(1000)->create(['code' => 'base_rate']);
        $adj = Fee::factory()->adjustment(-250)->create(['code' => 'com_discount', 'tld_id' => $tld->getKey()]);

        $snapshot = $this->resolver()
            ->resolve(FeeType::SellerCommission, $this->context('1000.00', ['tld' => $tld]))
            ->toSnapshot();

        $this->assertSame($base->getKey(), $snapshot['rule_id']);
        $this->assertSame(7500, $snapshot['amount_minor']);
        $this->assertCount(2, $snapshot['applied_rules']);

        $this->assertSame('base_rate', $snapshot['applied_rules'][0]['code']);
        $this->assertSame(FeeRole::Base->value, $snapshot['applied_rules'][0]['role']);

        $this->assertSame('com_discount', $snapshot['applied_rules'][1]['code']);
        $this->assertSame(FeeRole::Adjustment->value, $snapshot['applied_rules'][1]['role']);
        $this->assertSame(-2500, $snapshot['applied_rules'][1]['delta_minor']);
        $this->assertSame($adj->getKey(), $snapshot['applied_rules'][1]['rule_id']);
    }

    #[Test]
    public function it_reports_the_effective_rate_after_adjustments(): void
    {
        Fee::factory()->percentage(1000)->create();
        Fee::factory()->adjustment(-250)->create();

        $context = $this->context('1000.00');
        $fee = $this->resolver()->resolve(FeeType::SellerCommission, $context);

        $this->assertSame(750, $fee->effectiveRateBps($context->amount));
    }

    // -----------------------------------------------------------------
    // Schedule inspection: the guard that makes a dangerous edit visible.
    // -----------------------------------------------------------------

    /**
     * A base rule that flattens price bands within its scope must be reported,
     * even though the banded rules are still reachable for other TLDs. "Still
     * reachable somewhere" is not the same as "working where you meant it to".
     */
    #[Test]
    public function the_inspector_reports_a_base_rule_that_flattens_price_bands(): void
    {
        $tld = Tld::factory()->create(['tld' => 'com']);

        Fee::factory()->percentage(1000)->create(['code' => 'band_default']);
        Fee::factory()->percentage(800)->priceBand(1_000_000, 10_000_000)->create(['code' => 'band_mid']);
        Fee::factory()->percentage(600)->priceBand(10_000_001, null)->create(['code' => 'band_high']);

        $inspector = app(FeeScheduleInspector::class);
        $this->assertSame([], $inspector->findProblems(FeeType::SellerCommission));

        // The edit that used to overcharge silently.
        Fee::factory()->percentage(900)->create(['code' => 'com_flat', 'tld_id' => $tld->getKey()]);

        $problems = app(FeeScheduleInspector::class)->findProblems(FeeType::SellerCommission);

        $this->assertCount(1, $problems);
        $this->assertSame('flattened_bands', $problems[0]['kind']);
        $this->assertSame('com_flat', $problems[0]['rule']->code);

        $suppressed = array_map(fn ($r): string => (string) $r->code, $problems[0]['shadowed']);
        sort($suppressed);
        $this->assertSame(['band_high', 'band_mid'], $suppressed);
    }

    #[Test]
    public function the_inspector_does_not_flag_the_equivalent_adjustment(): void
    {
        $tld = Tld::factory()->create(['tld' => 'com']);

        Fee::factory()->percentage(1000)->create(['code' => 'band_default']);
        Fee::factory()->percentage(600)->priceBand(10_000_001, null)->create(['code' => 'band_high']);
        Fee::factory()->adjustment(-100)->create(['code' => 'com_adj', 'tld_id' => $tld->getKey()]);

        $this->assertSame([], app(FeeScheduleInspector::class)->findProblems(FeeType::SellerCommission));
    }

    #[Test]
    public function the_inspector_reports_a_rule_that_never_applies(): void
    {
        Fee::factory()->percentage(1000)->create(['code' => 'live_rule']);
        // Same scope, lower priority: it can never win.
        Fee::factory()->percentage(900)->create(['code' => 'dead_rule', 'priority' => -10]);

        $problems = app(FeeScheduleInspector::class)->findProblems(FeeType::SellerCommission);

        $this->assertCount(1, $problems);
        $this->assertSame('unreachable', $problems[0]['kind']);
        $this->assertSame('dead_rule', $problems[0]['rule']->code);
    }

    #[Test]
    public function previewing_a_change_reports_what_moves(): void
    {
        Fee::factory()->percentage(1000)->create(['code' => 'band_default']);

        $proposed = Fee::factory()->adjustment(-200)->make(['code' => 'proposed_discount']);

        $rows = app(FeeScheduleInspector::class)->previewChange(FeeType::SellerCommission, $proposed);

        $this->assertNotEmpty($rows);
        $this->assertTrue(
            collect($rows)->every(fn (array $row): bool => $row['changed'] === true),
            'A global 2% discount should move every sampled context.'
        );

        // $100.00 is one of the sampled price points; 10% of it is $10.00,
        // and the proposed two-point discount takes that to $8.00.
        $row = collect($rows)->firstWhere('before.amount.minor', 1_000);
        $this->assertNotNull($row, 'The $100 sample row was not produced.');
        $this->assertSame(800, $row['after']->amount->minor);
    }

    #[Test]
    public function the_rule_validator_rejects_a_negative_base_rate(): void
    {
        $rule = Fee::factory()->make(['role' => FeeRole::Base, 'rate_bps' => -100]);

        $problems = $this->resolver()->validateRule($rule);

        $this->assertNotEmpty($problems);
        $this->assertStringContainsString('base rate cannot be negative', $problems[0]);
    }

    #[Test]
    public function the_rule_validator_rejects_bounds_on_an_adjustment(): void
    {
        $rule = Fee::factory()->adjustment(-100)->make(['max_fee_minor' => 5000]);

        $this->assertNotEmpty(array_filter(
            $this->resolver()->validateRule($rule),
            fn (string $problem): bool => str_contains($problem, 'Floors and ceilings'),
        ));
    }

    #[Test]
    public function the_rule_validator_rejects_an_inverted_price_band(): void
    {
        $rule = Fee::factory()->priceBand(10_000, 1_000)->make();

        $this->assertNotEmpty(array_filter(
            $this->resolver()->validateRule($rule),
            fn (string $problem): bool => str_contains($problem, 'inverted'),
        ));
    }

    #[Test]
    public function the_seeded_schedule_charges_the_expected_commission_bands(): void
    {
        $this->seed(PlanSeeder::class);
        $this->seed(FeeSeeder::class);

        $resolver = $this->resolver();

        // $2,500 sits in the default 10% band.
        $this->assertSame('250.00', $resolver
            ->resolve(FeeType::SellerCommission, $this->context('2500.00'))
            ->amount->toMajorString());

        // $25,000 sits in the 8% mid band.
        $this->assertSame('2000.00', $resolver
            ->resolve(FeeType::SellerCommission, $this->context('25000.00'))
            ->amount->toMajorString());

        // $250,000 sits in the 6% high band.
        $this->assertSame('15000.00', $resolver
            ->resolve(FeeType::SellerCommission, $this->context('250000.00'))
            ->amount->toMajorString());
    }
}
