<?php

declare(strict_types=1);

namespace Tests\Feature\Financial;

use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\Payments\PaymentDistributor;
use App\Services\Payments\PaymentRegistrar;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

/**
 * Integration tests for PaymentDistributor.
 *
 * Verifies observable behavior:
 * - Pivots are created with correct applied_amount values
 * - Distribution follows due_date ascending order
 * - Preferred installment receives payment first
 * - Stops exactly when remaining reaches zero
 * - Handles overpayments (extra_amount)
 * - BC Math precision (0.01 minimum residue)
 *
 * Tests run through PaymentRegistrar (full pipeline) to validate
 * observable outcomes in the database.
 */
class PaymentDistributorTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $user;

    private Credit $credit;

    private PaymentRegistrar $registrar;

    protected function setUp(): void
    {
        parent::setUp();

        Event::fake(); // Suppress PaymentRegistered listeners

        $this->company = Company::factory()->create();
        $this->user = User::factory()->create(['company_id' => $this->company->id]);

        $this->credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => Client::factory()->create(['company_id' => $this->company->id])->id,
            'collector_user_id' => $this->user->id,
            'created_by_user_id' => $this->user->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 120_000,
            'installments_count' => 2,
            'due_date' => Carbon::today()->addDays(60),
        ]);

        $this->registrar = app(PaymentRegistrar::class);
    }

    private function makeInstallment(int $number, string $dueDate, float $total, float $principal, float $interest): Installment
    {
        return Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'installment_number' => $number,
            'status' => Installment::STATUS_PENDING,
            'due_date' => $dueDate,
            'principal_amount' => $principal,
            'interest_amount' => $interest,
            'total_amount' => $total,
            'principal_balance_after' => $total,
            'amount_paid' => 0,
        ]);
    }

    // ── Full payment to a single installment ─────────────────────────────────

    #[Test]
    public function it_distributes_full_payment_to_single_installment(): void
    {
        $inst = $this->makeInstallment(1, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        $payment = $this->registrar->forCredit(
            $this->credit,
            60_000,
            Carbon::today(),
            $this->user->id,
        );

        // One pivot row with full amount applied
        $pivotSum = DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $inst->id)
            ->value('applied_amount');

        $this->assertEquals(60_000, (float) $pivotSum);

        // Installment should be PAID
        $this->assertSame(Installment::STATUS_PAID, $inst->fresh()->status);
        $this->assertEquals(60_000, (float) $inst->fresh()->amount_paid);
    }

    // ── Partial payment ──────────────────────────────────────────────────────

    #[Test]
    public function it_distributes_partial_payment_and_leaves_installment_partial_paid(): void
    {
        $inst = $this->makeInstallment(1, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        $payment = $this->registrar->forCredit(
            $this->credit,
            25_000,
            Carbon::today(),
            $this->user->id,
        );

        $inst->refresh();
        $this->assertSame(Installment::STATUS_PARTIAL_PAID, $inst->status);
        $this->assertEquals(25_000, (float) $inst->amount_paid);

        // No extra_amount because total payment < installment total
        $this->assertEquals(0, (float) $payment->fresh()->extra_amount);
    }

    // ── Multi-installment cascade in due_date order ──────────────────────────

    #[Test]
    public function it_distributes_across_installments_in_due_date_ascending_order(): void
    {
        $first = $this->makeInstallment(1, Carbon::today()->addDays(15)->toDateString(), 60_000, 50_000, 10_000);
        $second = $this->makeInstallment(2, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        // Pay enough to cover the first installment fully and the second partially
        $payment = $this->registrar->forCredit(
            $this->credit,
            75_000,
            Carbon::today(),
            $this->user->id,
        );

        $firstApplied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $first->id)
            ->value('applied_amount');

        $secondApplied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $second->id)
            ->value('applied_amount');

        // First installment covered fully
        $this->assertEquals(60_000, $firstApplied, 'First installment must be fully covered first');
        // Remainder goes to second
        $this->assertEquals(15_000, $secondApplied, 'Remainder must go to second installment');

        $this->assertSame(Installment::STATUS_PAID, $first->fresh()->status);
        $this->assertSame(Installment::STATUS_PARTIAL_PAID, $second->fresh()->status);
    }

    // ── Preferred installment priority ────────────────────────────────────────

    #[Test]
    public function it_prioritizes_preferred_installment_over_due_date_order(): void
    {
        $first = $this->makeInstallment(1, Carbon::today()->addDays(10)->toDateString(), 60_000, 50_000, 10_000);
        $second = $this->makeInstallment(2, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        // Register payment explicitly for the SECOND installment
        $payment = $this->registrar->forInstallment(
            $second,
            40_000,
            Carbon::today(),
            $this->user->id,
        );

        $secondApplied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $second->id)
            ->value('applied_amount');

        // Second installment (preferred) should receive payment first
        $this->assertEquals(40_000, $secondApplied, 'Preferred installment must receive the payment first');

        // First installment should have zero applied
        $firstApplied = DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $first->id)
            ->exists();

        $this->assertFalse($firstApplied, 'Non-preferred installment should not receive payment when preferred is not fully paid');
    }

    // ── Zero amount payment ───────────────────────────────────────────────────

    #[Test]
    public function it_skips_distribution_when_amount_is_zero(): void
    {
        $distributor = app(PaymentDistributor::class);

        $inst = $this->makeInstallment(1, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        $payment = Payment::create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'amount' => 0,
            'applied_amount' => 0,
            'extra_amount' => 0,
            'payment_date' => Carbon::today()->toDateString(),
            'registered_by_user_id' => $this->user->id,
        ]);

        DB::transaction(function () use ($distributor, $payment) {
            $distributor->distribute($payment, $this->credit, 0, Carbon::today());
        });

        $pivotCount = DB::table('payment_installment')->where('payment_id', $payment->id)->count();
        $this->assertEquals(0, $pivotCount, 'No pivots should be created for zero-amount payment');
    }

    // ── Overpayment ──────────────────────────────────────────────────────────

    #[Test]
    public function it_records_extra_amount_when_payment_exceeds_total_due(): void
    {
        $inst = $this->makeInstallment(1, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        // Pay more than the installment total
        $payment = $this->registrar->forCredit(
            $this->credit,
            70_000,   // 10,000 excess
            Carbon::today(),
            $this->user->id,
        );

        $payment->refresh();

        // The installment receives exactly what it needs
        $applied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $inst->id)
            ->value('applied_amount');

        $this->assertEquals(60_000, $applied);

        // Extra amount must equal the excess
        $this->assertEquals(60_000, (float) $payment->applied_amount);
        $this->assertEquals(10_000, (float) $payment->extra_amount);

        $this->assertSame(Installment::STATUS_PAID, $inst->fresh()->status);
    }

    // ── Skips fully-paid installments ─────────────────────────────────────────

    #[Test]
    public function it_skips_installments_that_are_already_fully_paid(): void
    {
        // First installment fully paid by a prior payment
        $first = $this->makeInstallment(1, Carbon::today()->addDays(10)->toDateString(), 60_000, 50_000, 10_000);
        $second = $this->makeInstallment(2, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        // Pay the first installment
        $payment1 = $this->registrar->forCredit($this->credit, 60_000, Carbon::today(), $this->user->id);

        // Now pay again — should go to second installment since first is saturated
        $payment2 = $this->registrar->forCredit($this->credit, 30_000, Carbon::today(), $this->user->id);

        $firstApplied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment2->id)
            ->where('installment_id', $first->id)
            ->value('applied_amount');

        $secondApplied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment2->id)
            ->where('installment_id', $second->id)
            ->value('applied_amount');

        $this->assertEquals(0, $firstApplied, 'Already-paid installment must not receive more payment');
        $this->assertEquals(30_000, $secondApplied, 'Second installment must receive the next payment');
    }

    // ── BC Math precision: 0.01 ───────────────────────────────────────────────

    #[Test]
    public function it_applies_minimum_one_cent_payment_with_bc_math_precision(): void
    {
        $inst = $this->makeInstallment(1, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        $payment = $this->registrar->forCredit(
            $this->credit,
            0.01,
            Carbon::today(),
            $this->user->id,
        );

        $applied = (float) DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->where('installment_id', $inst->id)
            ->value('applied_amount');

        $this->assertEquals(0.01, $applied, 'A 0.01 payment must be applied with full precision');
        $this->assertSame(Installment::STATUS_PARTIAL_PAID, $inst->fresh()->status);
    }

    // ── Cumulative payments don't double-apply ────────────────────────────────

    #[Test]
    public function cumulative_payments_do_not_exceed_installment_total(): void
    {
        $inst = $this->makeInstallment(1, Carbon::today()->addDays(30)->toDateString(), 60_000, 50_000, 10_000);

        // Three partial payments: 20,000 + 20,000 + 20,000 = 60,000 (exactly full)
        foreach ([20_000, 20_000, 20_000] as $amount) {
            $this->registrar->forCredit($this->credit, $amount, Carbon::today(), $this->user->id);
        }

        $inst->refresh();

        $this->assertSame(Installment::STATUS_PAID, $inst->status);
        $this->assertEquals(60_000, (float) $inst->amount_paid);

        // Total pivots sum must equal installment total
        $totalApplied = (float) DB::table('payment_installment as pi')
            ->join('payments as p', 'p.id', '=', 'pi.payment_id')
            ->where('p.credit_id', $this->credit->id)
            ->where('p.voided', 0)
            ->where('pi.installment_id', $inst->id)
            ->sum('pi.applied_amount');

        $this->assertEquals(60_000, $totalApplied, 'Cumulative pivots must not exceed installment total');
    }

    // ── Payment applied_amount equals sum of pivots ────────────────────────────

    #[Test]
    public function payment_applied_amount_equals_sum_of_pivot_entries(): void
    {
        $this->makeInstallment(1, Carbon::today()->addDays(10)->toDateString(), 30_000, 25_000, 5_000);
        $this->makeInstallment(2, Carbon::today()->addDays(30)->toDateString(), 30_000, 25_000, 5_000);

        $payment = $this->registrar->forCredit($this->credit, 45_000, Carbon::today(), $this->user->id);
        $payment->refresh();

        $pivotSum = (float) DB::table('payment_installment')
            ->where('payment_id', $payment->id)
            ->sum('applied_amount');

        $this->assertEquals(
            round($pivotSum, 2),
            (float) $payment->applied_amount,
            'payment.applied_amount must exactly equal the sum of its pivot entries'
        );
    }
}
