<?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\PaymentMaterializer;
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 PaymentMaterializer.
 *
 * Verifies that after materialization:
 * - installment.amount_paid == SUM(non-voided pivot entries)
 * - installment.status transitions match amount_paid vs total_amount
 * - installment.paid_date is set only when fully paid
 * - payment.applied_amount == SUM(pivot entries)
 * - payment.extra_amount == amount - applied_amount (floored at 0)
 * - voided payments force applied_amount = extra_amount = 0
 *
 * Financial invariant tested:
 *   amount_paid is DERIVED from pivots — it must never diverge from them.
 */
class PaymentMaterializerTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $user;

    private Credit $credit;

    private PaymentMaterializer $materializer;

    private PaymentRegistrar $registrar;

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

        Event::fake();

        $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' => 60_000,
            'installments_count' => 1,
            'due_date' => Carbon::today()->addDays(60),
        ]);

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

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

    // ── Status: PENDING when no payment ──────────────────────────────────────

    #[Test]
    public function installment_remains_pending_when_no_payment_applied(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);

        // Materialize with no payments → should stay PENDING
        $this->materializer->materializeCredit($this->credit->fresh());

        $inst->refresh();
        $this->assertSame(Installment::STATUS_PENDING, $inst->status);
        $this->assertEquals(0, (float) $inst->amount_paid);
        $this->assertNull($inst->paid_date);
    }

    // ── Status: PARTIAL_PAID ───────────────────────────────────────────────────

    #[Test]
    public function installment_becomes_partial_paid_when_amount_paid_is_less_than_total(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);

        $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);
        $this->assertNull($inst->paid_date);
    }

    // ── Status: PAID ──────────────────────────────────────────────────────────

    #[Test]
    public function installment_becomes_paid_when_amount_paid_equals_total(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);

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

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

    // ── paid_date is set from payment_date ────────────────────────────────────

    #[Test]
    public function paid_date_is_set_to_payment_date_when_fully_paid(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);
        $paymentDay = Carbon::yesterday();

        $this->registrar->forCredit($this->credit, 60_000, $paymentDay, $this->user->id);

        $inst->refresh();
        $this->assertNotNull($inst->paid_date);
        $this->assertEquals($paymentDay->toDateString(), $inst->paid_date->toDateString());
    }

    // ── amount_paid derives exactly from pivot sums ────────────────────────────

    #[Test]
    public function amount_paid_matches_sum_of_non_voided_pivot_entries(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);

        // Three partial payments
        $this->registrar->forCredit($this->credit, 20_000, Carbon::today(), $this->user->id);
        $this->registrar->forCredit($this->credit, 15_000, Carbon::today(), $this->user->id);
        $this->registrar->forCredit($this->credit, 10_000, Carbon::today(), $this->user->id);

        $inst->refresh();

        $pivotSum = (float) DB::table('payment_installment as pi')
            ->join('payments as p', 'p.id', '=', 'pi.payment_id')
            ->where('pi.installment_id', $inst->id)
            ->where('p.voided', 0)
            ->sum('pi.applied_amount');

        $this->assertEquals(
            round($pivotSum, 2),
            (float) $inst->amount_paid,
            'installment.amount_paid must equal the sum of non-voided pivot entries'
        );
        $this->assertEquals(45_000, (float) $inst->amount_paid);
    }

    // ── Voided payments do not count toward amount_paid ───────────────────────

    #[Test]
    public function voided_payment_does_not_contribute_to_installment_amount_paid(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);

        // Register a payment and then manually void it (simulating PaymentVoider behavior)
        $payment = $this->registrar->forCredit($this->credit, 60_000, Carbon::today(), $this->user->id);

        // Void the payment and remove pivots (simulating void operation)
        DB::table('payment_installment')->where('payment_id', $payment->id)->delete();
        DB::table('payments')->where('id', $payment->id)->update(['voided' => 1]);

        // Re-materialize
        $this->materializer->materializeCredit($this->credit->fresh());

        $inst->refresh();

        // Voided payment removed from pivots → amount_paid must return to 0
        $this->assertEquals(0, (float) $inst->amount_paid);
        $this->assertSame(Installment::STATUS_PENDING, $inst->status);
        $this->assertNull($inst->paid_date);
    }

    // ── Voided payment: applied_amount and extra_amount forced to 0 ───────────

    #[Test]
    public function voided_payment_applied_and_extra_amounts_are_zeroed(): void
    {
        $this->makeInstallment(60_000, 50_000, 10_000);

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

        // Manually void without cleaning pivots — materializer should still zero out amounts
        DB::table('payments')->where('id', $payment->id)->update(['voided' => 1]);

        $this->materializer->materializeCredit($this->credit->fresh());

        $payment->refresh();
        $this->assertEquals(0, (float) $payment->applied_amount, 'Voided payment applied_amount must be 0');
        $this->assertEquals(0, (float) $payment->extra_amount, 'Voided payment extra_amount must be 0');
    }

    // ── Extra amount materializes correctly for overpayment ───────────────────

    #[Test]
    public function extra_amount_equals_payment_amount_minus_applied_amount(): void
    {
        $this->makeInstallment(60_000, 50_000, 10_000);

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

        // Financial invariant: applied + extra = amount
        $this->assertEquals(60_000, (float) $payment->applied_amount);
        $this->assertEquals(15_000, (float) $payment->extra_amount);
        $this->assertEquals(
            round((float) $payment->applied_amount + (float) $payment->extra_amount, 2),
            (float) $payment->amount,
            'applied_amount + extra_amount must equal payment amount'
        );
    }

    // ── Cumulative rounding precision ─────────────────────────────────────────

    #[Test]
    public function cumulative_rounding_is_absorbed_correctly_over_multiple_payments(): void
    {
        // Three installments: each 1/3 of 100 = 33.33 + 33.33 + 33.34
        $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' => 100,
            'due_date' => Carbon::today()->addDays(90),
        ]);

        $inst1 = Installment::factory()->create([
            'company_id' => $this->company->id, 'credit_id' => $credit->id,
            'installment_number' => 1, 'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(30)->toDateString(),
            'principal_amount' => 27.78, 'interest_amount' => 5.55, 'total_amount' => 33.33,
            'principal_balance_after' => 33.33, 'amount_paid' => 0,
        ]);
        $inst2 = Installment::factory()->create([
            'company_id' => $this->company->id, 'credit_id' => $credit->id,
            'installment_number' => 2, 'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(60)->toDateString(),
            'principal_amount' => 27.78, 'interest_amount' => 5.55, 'total_amount' => 33.33,
            'principal_balance_after' => 33.33, 'amount_paid' => 0,
        ]);
        $inst3 = Installment::factory()->create([
            'company_id' => $this->company->id, 'credit_id' => $credit->id,
            'installment_number' => 3, 'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(90)->toDateString(),
            'principal_amount' => 27.78, 'interest_amount' => 5.56, 'total_amount' => 33.34,
            'principal_balance_after' => 33.34, 'amount_paid' => 0,
        ]);

        // Pay each installment exactly
        $this->registrar->forCredit($credit, 33.33, Carbon::today(), $this->user->id);
        $this->registrar->forCredit($credit, 33.33, Carbon::today(), $this->user->id);
        $this->registrar->forCredit($credit, 33.34, Carbon::today(), $this->user->id);

        $inst1->refresh();
        $inst2->refresh();
        $inst3->refresh();

        // All three installments must be PAID
        $this->assertSame(Installment::STATUS_PAID, $inst1->status, 'Installment 1 must be PAID');
        $this->assertSame(Installment::STATUS_PAID, $inst2->status, 'Installment 2 must be PAID');
        $this->assertSame(Installment::STATUS_PAID, $inst3->status, 'Installment 3 must be PAID');

        // Financial invariant: total amount_paid = 100.00
        $totalPaid = (float) $inst1->amount_paid + (float) $inst2->amount_paid + (float) $inst3->amount_paid;
        $this->assertEquals(100.00, round($totalPaid, 2), 'Total amount_paid must equal total receivable');
    }

    // ── Re-materialization is idempotent ──────────────────────────────────────

    #[Test]
    public function re_materializing_multiple_times_is_idempotent(): void
    {
        $inst = $this->makeInstallment(60_000, 50_000, 10_000);

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

        // Call materializer twice more
        $this->materializer->materializeCredit($this->credit->fresh());
        $this->materializer->materializeCredit($this->credit->fresh());

        $inst->refresh();

        // amount_paid must not double-count
        $this->assertEquals(30_000, (float) $inst->amount_paid);
        $this->assertSame(Installment::STATUS_PARTIAL_PAID, $inst->status);
    }

    // ── Multiple payments on multiple installments ─────────────────────────────

    #[Test]
    public function each_installment_amount_paid_matches_its_own_pivot_sum(): void
    {
        $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,
            'due_date' => Carbon::today()->addDays(60),
        ]);

        $inst1 = Installment::factory()->create([
            'company_id' => $this->company->id, 'credit_id' => $credit->id,
            'installment_number' => 1, 'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(15)->toDateString(),
            'principal_amount' => 50_000, 'interest_amount' => 10_000, 'total_amount' => 60_000,
            'principal_balance_after' => 60_000, 'amount_paid' => 0,
        ]);
        $inst2 = Installment::factory()->create([
            'company_id' => $this->company->id, 'credit_id' => $credit->id,
            'installment_number' => 2, 'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(30)->toDateString(),
            'principal_amount' => 50_000, 'interest_amount' => 10_000, 'total_amount' => 60_000,
            'principal_balance_after' => 60_000, 'amount_paid' => 0,
        ]);

        // Payment covers both installments
        $this->registrar->forCredit($credit, 90_000, Carbon::today(), $this->user->id);

        foreach ([$inst1, $inst2] as $inst) {
            $inst->refresh();

            $pivotSum = round((float) DB::table('payment_installment as pi')
                ->join('payments as p', 'p.id', '=', 'pi.payment_id')
                ->where('pi.installment_id', $inst->id)
                ->where('p.voided', 0)
                ->sum('pi.applied_amount'), 2);

            $this->assertEquals(
                $pivotSum,
                round((float) $inst->amount_paid, 2),
                "Installment #{$inst->installment_number}: amount_paid must equal its pivot sum"
            );
        }
    }
}
