<?php

declare(strict_types=1);

namespace Tests\Feature\Financial;

use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\CreditAuditLog;
use App\Models\Installment;
use App\Models\User;
use App\Services\Credits\CreditInstallmentRescheduler;
use App\Services\Payments\PaymentRegistrar;
use App\ValueObjects\Money;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * Integration tests for CreditInstallmentRescheduler.
 *
 * Verifies:
 * - Reschedule replaces all installments atomically
 * - New total must match old total exactly (financial invariant)
 * - Principal/interest ratio preserved from the original plan
 * - Blocked when any payment has been applied (Política Financiera A)
 * - SuperAdmin cannot execute reschedules
 * - Cross-company access is denied
 * - Audit log is written with before/after snapshots
 * - canReschedule() correctly reflects eligibility
 */
class CreditInstallmentReschedulerTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $user;

    private Credit $credit;

    private CreditInstallmentRescheduler $rescheduler;

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

        // Original plan: 2 installments, principal ratio = 0.83333... (500/600 per installment)
        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'installment_number' => 1,
            'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(30)->toDateString(),
            'principal_amount' => 500,
            'interest_amount' => 100,
            'total_amount' => 600,
            'principal_balance_after' => 600,
            'amount_paid' => 0,
        ]);
        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'installment_number' => 2,
            'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::today()->addDays(60)->toDateString(),
            'principal_amount' => 500,
            'interest_amount' => 100,
            'total_amount' => 600,
            'principal_balance_after' => 600,
            'amount_paid' => 0,
        ]);

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

    /**
     * Original plan: 2×600 = 1200 total, principal=1000, interest=200
     * Ratio = 1000/1200 = 0.83333333...
     */
    private function originalTotal(): float
    {
        return 1_200;
    }

    private function principalRatio(): string
    {
        return bcdiv('1000', '1200', 8);
    }

    // ── Successful reschedule ─────────────────────────────────────────────────

    #[Test]
    public function it_replaces_installments_with_new_schedule_of_same_total(): void
    {
        $this->actingAs($this->user);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 800],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 400],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule, 'Test reschedule');

        $installments = $credit->installments->sortBy('installment_number')->values();

        $this->assertCount(2, $installments);
        $this->assertEquals(800, (float) $installments[0]->total_amount);
        $this->assertEquals(400, (float) $installments[1]->total_amount);
    }

    #[Test]
    public function new_installment_count_can_differ_from_original(): void
    {
        $this->actingAs($this->user);

        // 2 installments → 3 installments, same total of 1200
        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(30)->toDateString(), 'amount' => 400],
            ['due_date' => Carbon::today()->addDays(60)->toDateString(), 'amount' => 400],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 400],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule);

        $this->assertCount(3, $credit->installments);
    }

    // ── Principal/interest ratio preservation ─────────────────────────────────

    #[Test]
    public function principal_interest_ratio_is_preserved_in_rescheduled_installments(): void
    {
        $this->actingAs($this->user);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 800],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 400],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule);
        $installments = $credit->installments->sortBy('installment_number')->values();
        $ratio = $this->principalRatio();

        foreach ($installments as $inst) {
            $total = Money::of($inst->total_amount);

            // Mirror the rescheduler computation exactly (BC Math truncates, not rounds)
            $principalMoney = $total->multiply($ratio);
            $interestMoney = $total->subtract($principalMoney);
            $expectedPrincipal = $principalMoney->value();
            $expectedInterest = $interestMoney->value();

            $this->assertEquals(
                $expectedPrincipal,
                (float) $inst->principal_amount,
                "Installment #{$inst->installment_number}: principal must match ratio"
            );
            $this->assertEquals(
                $expectedInterest,
                (float) $inst->interest_amount,
                "Installment #{$inst->installment_number}: interest must equal total - principal (BC internal)"
            );

            // Also verify: principal + interest == total (no rounding leak)
            $this->assertEqualsWithDelta(
                (float) $inst->total_amount,
                (float) $inst->principal_amount + (float) $inst->interest_amount,
                0.01,
                "Installment #{$inst->installment_number}: principal + interest must equal total"
            );
        }
    }

    #[Test]
    public function sum_of_new_principals_approximately_equals_sum_of_old_principals(): void
    {
        $this->actingAs($this->user);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 700],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 500],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule);
        $installments = $credit->installments;

        $newPrincipalSum = $installments->sum(fn ($i) => (float) $i->principal_amount);
        $newInterestSum = $installments->sum(fn ($i) => (float) $i->interest_amount);

        // BC Math truncates (does not round) per installment, so sums may differ from the
        // original by up to N * 0.005 where N = number of installments.
        // The invariant is that each installment individually preserves the ratio.
        $this->assertEqualsWithDelta(1_000, $newPrincipalSum, 0.05,
            'Sum of new principals must be within 5 cents of original (BC Math truncation)');
        $this->assertEqualsWithDelta(200, $newInterestSum, 0.05,
            'Sum of new interest must be within 5 cents of original (BC Math truncation)');

        // The hard invariant: total (principal + interest) per installment = scheduled amount
        foreach ($installments as $inst) {
            $this->assertEqualsWithDelta(
                (float) $inst->total_amount,
                (float) $inst->principal_amount + (float) $inst->interest_amount,
                0.01,
                "Installment #{$inst->installment_number}: principal + interest must equal total"
            );
        }
    }

    #[Test]
    public function total_amount_sum_matches_original_exactly(): void
    {
        $this->actingAs($this->user);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 800],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 400],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule);
        $newTotal = $credit->installments->sum(fn ($i) => (float) $i->total_amount);

        $this->assertEquals(1_200, round($newTotal, 2), 'Sum of new installments must equal original total');
    }

    // ── Audit log ─────────────────────────────────────────────────────────────

    #[Test]
    public function it_creates_an_audit_log_entry_on_reschedule(): void
    {
        $this->actingAs($this->user);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $this->rescheduler->reschedule($this->credit, $newSchedule, 'Customer request');

        $log = CreditAuditLog::where('credit_id', $this->credit->id)
            ->where('action', 'installments_rescheduled')
            ->first();

        $this->assertNotNull($log, 'Audit log must be created with action installments_rescheduled');
        $this->assertEquals($this->user->id, $log->user_id);
        $this->assertEquals('Customer request', $log->data['reason']);
        $this->assertArrayHasKey('before', $log->data);
        $this->assertArrayHasKey('after', $log->data);
    }

    #[Test]
    public function audit_log_before_snapshot_contains_original_installments(): void
    {
        $this->actingAs($this->user);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $this->rescheduler->reschedule($this->credit, $newSchedule);

        $log = CreditAuditLog::where('credit_id', $this->credit->id)
            ->where('action', 'installments_rescheduled')
            ->first();

        $this->assertCount(2, $log->data['before']['installments'], 'Before snapshot must have 2 original installments');
        $this->assertCount(2, $log->data['after']['installments'], 'After snapshot must have 2 new installments');
    }

    // ── Guard: total mismatch ─────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_new_schedule_total_does_not_match_original(): void
    {
        $this->actingAs($this->user);

        // Total = 1100 instead of 1200
        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 700],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 400],
        ];

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/suma de las nuevas cuotas/');

        $this->rescheduler->reschedule($this->credit, $newSchedule);
    }

    // ── Guard: applied payments ────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_credit_has_applied_payments(): void
    {
        $this->actingAs($this->user);

        // Register a payment so there are applied amounts in pivots
        $this->registrar->forCredit($this->credit, 100, Carbon::today(), $this->user->id);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/ya tiene pagos aplicados/');

        $this->rescheduler->reschedule($this->credit, $newSchedule);
    }

    // ── Guard: SuperAdmin ─────────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_called_by_super_admin(): void
    {
        // SuperAdmin: company_id = null + 'super_admin' role
        $superAdmin = User::factory()->create(['company_id' => null]);

        // Assign the super_admin role using Spatie
        try {
            $superAdmin->assignRole('super_admin');
        } catch (\Throwable $e) {
            // Role may not exist in test environment; create it
            Role::firstOrCreate(['name' => 'super_admin', 'guard_name' => 'web']);
            $superAdmin->assignRole('super_admin');
        }

        $this->actingAs($superAdmin);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/SuperAdmin no está autorizado/');

        $this->rescheduler->reschedule($this->credit, $newSchedule);
    }

    // ── Guard: cross-company ───────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_user_belongs_to_different_company(): void
    {
        $otherCompany = Company::factory()->create();
        $otherUser = User::factory()->create(['company_id' => $otherCompany->id]);

        $this->actingAs($otherUser);

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/No está autorizado/');

        $this->rescheduler->reschedule($this->credit, $newSchedule);
    }

    // ── Guard: unauthenticated ─────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_no_user_is_authenticated(): void
    {
        // Ensure no user is authenticated
        Auth::logout();

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/Usuario no autenticado/');

        $this->rescheduler->reschedule($this->credit, $newSchedule);
    }

    // ── Guard: no installments ────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_credit_has_no_installments(): void
    {
        $this->actingAs($this->user);

        $emptyCredit = 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,
            'due_date' => Carbon::today()->addDays(60),
        ]);
        // No installments created

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/no tiene cuotas/');

        $this->rescheduler->reschedule($emptyCredit, [
            ['due_date' => Carbon::today()->addDays(30)->toDateString(), 'amount' => 60_000],
        ]);
    }

    // ── Validation: empty schedule ─────────────────────────────────────────────

    #[Test]
    public function it_throws_when_new_schedule_is_empty(): void
    {
        $this->actingAs($this->user);

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/al menos una cuota/');

        $this->rescheduler->reschedule($this->credit, []);
    }

    // ── Validation: zero amount ────────────────────────────────────────────────

    #[Test]
    public function it_throws_when_new_schedule_contains_zero_amount(): void
    {
        $this->actingAs($this->user);

        $this->expectException(Exception::class);
        $this->expectExceptionMessageMatches('/debe ser mayor a 0/');

        $this->rescheduler->reschedule($this->credit, [
            ['due_date' => Carbon::today()->addDays(30)->toDateString(), 'amount' => 0],
            ['due_date' => Carbon::today()->addDays(60)->toDateString(), 'amount' => 1_200],
        ]);
    }

    // ── Schedule sorted by due_date ────────────────────────────────────────────

    #[Test]
    public function new_installments_are_sorted_by_due_date_ascending(): void
    {
        $this->actingAs($this->user);

        // Submit schedule out of order
        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 400],
            ['due_date' => Carbon::today()->addDays(30)->toDateString(), 'amount' => 800],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule);
        $installments = $credit->installments->sortBy('installment_number')->values();

        $this->assertEquals(
            Carbon::today()->addDays(30)->toDateString(),
            $installments[0]->due_date->toDateString(),
            'First installment must have the earliest due_date'
        );
        $this->assertEquals(
            Carbon::today()->addDays(90)->toDateString(),
            $installments[1]->due_date->toDateString(),
            'Second installment must have the later due_date'
        );
    }

    // ── canReschedule() ───────────────────────────────────────────────────────

    #[Test]
    public function can_reschedule_returns_true_when_no_payments_applied(): void
    {
        $this->actingAs($this->user);

        $this->assertTrue($this->rescheduler->canReschedule($this->credit));
    }

    #[Test]
    public function can_reschedule_returns_false_when_payments_have_been_applied(): void
    {
        $this->actingAs($this->user);

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

        $this->assertFalse($this->rescheduler->canReschedule($this->credit->fresh()));
    }

    #[Test]
    public function can_reschedule_returns_false_for_super_admin(): void
    {
        $superAdmin = User::factory()->create(['company_id' => null]);

        try {
            $superAdmin->assignRole('super_admin');
        } catch (\Throwable $e) {
            Role::firstOrCreate(['name' => 'super_admin', 'guard_name' => 'web']);
            $superAdmin->assignRole('super_admin');
        }

        $this->actingAs($superAdmin);

        $this->assertFalse($this->rescheduler->canReschedule($this->credit));
    }

    // ── Atomicity: old installments replaced ──────────────────────────────────

    #[Test]
    public function original_installments_are_deleted_and_replaced_atomically(): void
    {
        $this->actingAs($this->user);

        $originalIds = $this->credit->installments->pluck('id')->toArray();

        $newSchedule = [
            ['due_date' => Carbon::today()->addDays(45)->toDateString(), 'amount' => 600],
            ['due_date' => Carbon::today()->addDays(90)->toDateString(), 'amount' => 600],
        ];

        $credit = $this->rescheduler->reschedule($this->credit, $newSchedule);

        $newIds = $credit->installments->pluck('id')->toArray();

        // All original IDs should be gone
        foreach ($originalIds as $originalId) {
            $this->assertNotContains($originalId, $newIds, 'Original installment IDs must not exist after reschedule');
        }

        // Fresh installments should exist
        $this->assertCount(2, $newIds);
    }
}
