<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Jobs\SyncCreditStatusJob;
use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\CreditStatusSyncService;
use App\Services\PaymentManager;
use App\Services\Payments\HeldPaymentReviewException;
use App\Services\Payments\HeldPaymentReviewService;
use App\Services\PaymentSyncService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Str;
use Mockery\MockInterface;
use PDOException;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class HeldPaymentReviewTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

    private User $admin;

    private Credit $credit;

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

        foreach (['admin', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
        }

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

        $client = Client::factory()->create(['company_id' => $this->company->id]);
        $this->credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $this->collector->id,
            'created_by_user_id' => $this->collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 60_000,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(40),
            'due_date' => now()->addDays(5),
        ]);
        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => 60_000,
            'principal_amount' => 48_000,
            'interest_amount' => 12_000,
            'due_date' => now()->addDays(5),
        ]);

        $this->actingAs($this->admin);
    }

    private function held(array $overrides = []): HeldPayment
    {
        return HeldPayment::create(array_merge([
            'company_id' => $this->company->id,
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $this->credit->id,
            'captured_by_user_id' => $this->collector->id,
            'synced_by_user_id' => $this->collector->id,
            'amount' => 10_000,
            'payment_method' => 'cash',
            'payment_date' => now()->subDays(20)->toDateString(),
            'offline_created_at' => now()->subDays(20),
            'reason' => HeldPayment::REASON_STALE,
            'payload' => [],
            'status' => HeldPayment::STATUS_PENDING,
        ], $overrides));
    }

    #[Test]
    public function aprobar_registra_el_pago_con_la_fecha_la_clave_y_el_cobrador_originales(): void
    {
        $held = $this->held();

        $payment = app(HeldPaymentReviewService::class)->approve($held, $this->admin);

        $this->assertSame($held->idempotency_key, $payment->idempotency_key);
        $this->assertSame($this->collector->id, $payment->registered_by_user_id);
        $this->assertSame(now()->subDays(20)->toDateString(), $payment->payment_date->toDateString());

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_APPROVED, $held->status);
        $this->assertSame($payment->id, $held->payment_id);
        $this->assertSame($this->admin->id, $held->resolved_by_user_id);
        $this->assertNotNull($held->resolved_at);
    }

    #[Test]
    public function aprobar_dos_veces_no_crea_dos_pagos(): void
    {
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        $service->approve($held, $this->admin);

        $this->expectException(HeldPaymentReviewException::class);
        try {
            $service->approve($held->fresh(), $this->admin);
        } finally {
            $this->assertSame(1, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
        }
    }

    #[Test]
    public function no_se_aprueba_si_el_credito_ya_no_admite_pagos(): void
    {
        $this->credit->update(['status' => Credit::STATUS_PAID]);
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        $blocker = (string) $service->approvalBlocker($held);
        $this->assertStringContainsString('ya no está activo', $blocker);
        // La aprobación no filtra por cobrador: no hay que sugerir que cambió de cobrador.
        $this->assertStringNotContainsString('cobrador', $blocker);

        $this->expectException(HeldPaymentReviewException::class);
        $service->approve($held, $this->admin);
    }

    #[Test]
    public function no_se_aprueba_un_retenido_ilegible(): void
    {
        $held = $this->held(['reason' => HeldPayment::REASON_INVALID, 'amount' => null]);

        $this->assertNotNull(app(HeldPaymentReviewService::class)->approvalBlocker($held));
    }

    #[Test]
    public function un_invalido_con_datos_completos_explica_su_motivo_real(): void
    {
        // El lote también usa `invalid` cuando el capturador no es de la empresa:
        // los datos están bien, no hay que decirle al admin que son ilegibles.
        $held = $this->held([
            'reason' => HeldPayment::REASON_INVALID,
            'reason_detail' => 'El usuario que capturó el cobro no pertenece a la empresa o ya no tiene rol en la app.',
        ]);

        $blocker = (string) app(HeldPaymentReviewService::class)->approvalBlocker($held);

        $this->assertStringContainsString('no pertenece a la empresa', $blocker);
        $this->assertStringNotContainsString('ilegibles', $blocker);
    }

    #[Test]
    public function no_se_aprueba_un_cobro_con_fecha_futura(): void
    {
        $manana = now()->addDay();
        $held = $this->held(['reason' => HeldPayment::REASON_FUTURE_DATE, 'payment_date' => $manana->toDateString()]);
        $service = app(HeldPaymentReviewService::class);

        $this->assertStringContainsString("La fecha de cobro ({$manana->format('d/m/Y')}) todavía no llega", (string) $service->approvalBlocker($held));

        try {
            $service->approve($held, $this->admin);
            $this->fail('Debió negarse a aprobar un cobro con fecha futura');
        } catch (HeldPaymentReviewException) {
            // esperado
        }

        $this->assertSame(HeldPayment::STATUS_PENDING, $held->fresh()->status);
        $this->assertSame(0, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
    }

    #[Test]
    public function un_admin_de_otra_empresa_no_puede_aprobar_ni_rechazar(): void
    {
        $otraEmpresa = Company::factory()->create();
        $ajeno = User::factory()->create(['company_id' => $otraEmpresa->id]);
        $ajeno->assignRole('admin');
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        try {
            $service->approve($held, $ajeno);
            $this->fail('Debió negarse a aprobar un cobro de otra empresa');
        } catch (HeldPaymentReviewException $e) {
            $this->assertSame('No puedes revisar cobros de otra empresa.', $e->getMessage());
        }

        try {
            $service->reject($held, $ajeno, 'No es de esta empresa');
            $this->fail('Debió negarse a rechazar un cobro de otra empresa');
        } catch (HeldPaymentReviewException $e) {
            $this->assertSame('No puedes revisar cobros de otra empresa.', $e->getMessage());
        }

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_PENDING, $held->status);
        $this->assertNull($held->resolved_by_user_id);
        $this->assertNull($held->resolved_at);
        $this->assertNull($held->resolution_notes);
        $this->assertSame(0, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
    }

    #[Test]
    public function no_se_aprueba_si_ya_existe_un_pago_con_la_misma_clave(): void
    {
        // Carrera entre pestañas: una aplicó el cobro mientras la otra lo mandaba a revisión.
        $held = $this->held();
        $aplicado = app(PaymentSyncService::class)->applyToCredit($this->credit, [
            'amount' => 10_000, 'payment_date' => now()->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, $held->idempotency_key);

        $this->assertSame("Este cobro ya está aplicado (pago #{$aplicado->id}).", app(HeldPaymentReviewService::class)->approvalBlocker($held));
    }

    #[Test]
    public function una_clave_ya_aplicada_en_otra_empresa_no_muestra_el_pago_ajeno(): void
    {
        $otra = Company::factory()->create();
        $cobradorAjeno = User::factory()->create(['company_id' => $otra->id]);
        $creditoAjeno = Credit::factory()->create([
            'company_id' => $otra->id,
            'client_id' => Client::factory()->create(['company_id' => $otra->id])->id,
            'collector_user_id' => $cobradorAjeno->id,
            'created_by_user_id' => $cobradorAjeno->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 60_000,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(10),
            'due_date' => now()->addDays(20),
        ]);
        Installment::factory()->create([
            'company_id' => $otra->id,
            'credit_id' => $creditoAjeno->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => 60_000,
            'principal_amount' => 48_000,
            'interest_amount' => 12_000,
            'due_date' => now()->addDays(20),
        ]);
        $held = $this->held();
        app(PaymentSyncService::class)->applyToCredit($creditoAjeno, [
            'amount' => 10_000, 'payment_date' => now()->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $cobradorAjeno->id, $held->idempotency_key);

        $this->assertSame('Este cobro ya está aplicado.', app(HeldPaymentReviewService::class)->approvalBlocker($held));
    }

    #[Test]
    public function una_clave_repetida_al_aplicar_se_traduce_a_ya_aplicado(): void
    {
        // Si el índice único de payments.idempotency_key salta al aplicar, el admin
        // ve un mensaje claro y no el SQL.
        $this->partialMock(PaymentSyncService::class, function (MockInterface $mock): void {
            $mock->shouldReceive('applyToCredit')->andThrow($this->uniqueViolation('payments.payments_idempotency_key_unique'));
        });
        $held = $this->held();

        try {
            app(HeldPaymentReviewService::class)->approve($held, $this->admin);
            $this->fail('Debió traducir la clave repetida');
        } catch (HeldPaymentReviewException $e) {
            $this->assertSame('Este cobro ya está aplicado.', $e->getMessage());
            $this->assertNull($e->getPrevious(), 'sin la QueryException encadenada: trae el SQL con los valores');
        }

        $this->assertSame(HeldPayment::STATUS_PENDING, $held->fresh()->status);
    }

    #[Test]
    public function un_choque_con_otro_indice_unico_no_se_disfraza_de_ya_aplicado(): void
    {
        // Solo el índice de idempotencia significa "ya aplicado"; cualquier otro es
        // un error de verdad: se reporta una vez, sin datos, y sube como un aviso
        // genérico que la pantalla muestra sin volver a reportarlo.
        Exceptions::fake();
        $choque = $this->uniqueViolation('installments.installments_credit_id_installment_number_unique');
        $this->partialMock(PaymentSyncService::class, function (MockInterface $mock) use ($choque): void {
            $mock->shouldReceive('applyToCredit')->andThrow($choque);
        });
        $held = $this->held();

        try {
            app(HeldPaymentReviewService::class)->approve($held, $this->admin);
            $this->fail('Debió negarse por el choque');
        } catch (HeldPaymentReviewException $e) {
            $this->assertSame(
                'No se pudo aplicar el cobro por un conflicto de datos. Quedó registrado para revisión técnica; recarga e inténtalo de nuevo.',
                $e->getMessage(),
            );
            $this->assertNull($e->getPrevious(), 'sin la QueryException encadenada: trae el SQL con los valores');
        }

        $this->assertSame(HeldPayment::STATUS_PENDING, $held->fresh()->status);
        Exceptions::assertReportedCount(1);
        Exceptions::assertReported(fn (RuntimeException $e) => $e->getPrevious() === null
            && str_contains($e->getMessage(), '23000')
            && ! str_contains($e->getMessage(), 'valor-privado'));
        Exceptions::assertNotReported(UniqueConstraintViolationException::class);
    }

    #[Test]
    public function no_se_aprueba_un_credito_que_ya_fue_reemplazado(): void
    {
        // Un hijo (refinanciación, extensión…) sustituye al crédito: el pago va al vigente.
        Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $this->credit->client_id,
            'collector_user_id' => $this->collector->id,
            'created_by_user_id' => $this->collector->id,
            'parent_credit_id' => $this->credit->id,
            'status' => Credit::STATUS_ACTIVE,
        ]);
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        $this->assertStringContainsString('ya no está activo', (string) $service->approvalBlocker($held));

        try {
            $service->approve($held, $this->admin);
            $this->fail('Debió negarse a aplicar sobre un crédito reemplazado');
        } catch (HeldPaymentReviewException $e) {
            $this->assertStringContainsString('ya no está activo', $e->getMessage());
        }

        $this->assertSame(0, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
    }

    /** Una violación de índice único como la arma Laravel con un error 1062 de MySQL 8. */
    private function uniqueViolation(string $index): UniqueConstraintViolationException
    {
        $pdo = new PDOException("SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'valor-privado' for key '{$index}'");
        $pdo->errorInfo = ['23000', 1062, "Duplicate entry 'valor-privado' for key '{$index}'"];

        return new UniqueConstraintViolationException('mysql', 'update `payments` set `idempotency_key` = ? where `id` = ?', ['valor-privado', 1], $pdo);
    }

    #[Test]
    public function no_se_aprueba_si_el_capturador_del_telefono_no_coincide(): void
    {
        // Defensa en profundidad: el teléfono dijo que lo cobró otro usuario, pero
        // el retenido quedó a nombre de alguien distinto. No se adivina.
        $otro = User::factory()->create(['company_id' => $this->company->id]);
        $service = app(HeldPaymentReviewService::class);

        $this->assertSame(
            'No se pudo confirmar quién cobró este pago: regístralo a mano a nombre de quien corresponda y rechaza este.',
            $service->approvalBlocker($this->held(['reason' => HeldPayment::REASON_MANUAL, 'payload' => ['captured_by_user_id' => $otro->id]])),
        );

        // El mismo id, aunque llegue como texto, sí coincide.
        $this->assertNull($service->approvalBlocker($this->held(['payload' => ['captured_by_user_id' => (string) $this->collector->id]])));

        // Vacío = el teléfono no dijo nada (ítems viejos): vale el guardado.
        $this->assertNull($service->approvalBlocker($this->held(['payload' => ['captured_by_user_id' => '']])));

        // Un valor que no es un id no confirma nada (true no pasa por el usuario #1).
        foreach ([true, 'abc', 0, '12.5'] as $raro) {
            $this->assertStringContainsString(
                'No se pudo confirmar quién cobró',
                (string) $service->approvalBlocker($this->held(['payload' => ['captured_by_user_id' => $raro]])),
            );
        }
    }

    #[Test]
    public function no_se_aprueba_un_cobro_anterior_al_inicio_del_credito(): void
    {
        // El crédito empezó hace 40 días: un cobro de hace 45 es un reloj del teléfono mal puesto.
        $fecha = now()->subDays(45);
        $held = $this->held(['payment_date' => $fecha->toDateString()]);

        $this->assertSame(
            "La fecha de cobro ({$fecha->format('d/m/Y')}) es anterior al inicio del crédito: revísala antes de aplicarlo.",
            app(HeldPaymentReviewService::class)->approvalBlocker($held),
        );
    }

    #[Test]
    public function si_falla_el_cierre_dentro_de_la_aprobacion_no_queda_nada_aplicado(): void
    {
        // El job encolado que en producción cierra el crédito más tarde no corre acá.
        Bus::fake([SyncCreditStatusJob::class]);
        $this->partialMock(CreditStatusSyncService::class, function (MockInterface $mock): void {
            $mock->shouldReceive('forceSync')->andThrow(new RuntimeException('cierre caído'));
        });
        // Paga todo el crédito: dispara el cierre automático.
        $held = $this->held(['amount' => 60_000]);

        try {
            app(HeldPaymentReviewService::class)->approve($held, $this->admin);
            $this->fail('Debió propagar el fallo del cierre');
        } catch (RuntimeException $e) {
            $this->assertSame('cierre caído', $e->getMessage());
        }

        $this->assertSame(HeldPayment::STATUS_PENDING, $held->fresh()->status);
        $this->assertNull($held->fresh()->payment_id);
        $this->assertSame(0, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
        $this->assertSame(0.0, (float) Installment::query()->where('credit_id', $this->credit->id)->value('amount_paid'));
    }

    #[Test]
    public function no_se_rechaza_un_retenido_ya_aprobado(): void
    {
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);
        $service->approve($held, $this->admin);

        try {
            $service->reject($held, $this->admin, 'Llegó tarde');
            $this->fail('Debió negarse a rechazar un retenido ya aprobado');
        } catch (HeldPaymentReviewException $e) {
            $this->assertSame('Este cobro ya fue revisado.', $e->getMessage());
        }

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_APPROVED, $held->status);
        $this->assertNull($held->resolution_notes);
    }

    #[Test]
    public function un_super_admin_aprueba_en_la_empresa_del_retenido(): void
    {
        Role::firstOrCreate(['name' => 'super_admin', 'guard_name' => 'web']);
        $superAdmin = User::factory()->create(['company_id' => null]);
        $superAdmin->assignRole('super_admin');
        $held = $this->held();

        $payment = app(HeldPaymentReviewService::class)->approve($held, $superAdmin);

        $this->assertSame($this->company->id, $payment->company_id);
        $this->assertSame($this->collector->id, $payment->registered_by_user_id);
        $this->assertSame($superAdmin->id, $held->fresh()->resolved_by_user_id);
    }

    #[Test]
    public function un_cobrador_de_la_misma_empresa_no_puede_aprobar_ni_rechazar(): void
    {
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        foreach ([fn () => $service->approve($held, $this->collector), fn () => $service->reject($held, $this->collector, 'Me equivoqué')] as $intento) {
            try {
                $intento();
                $this->fail('Un cobrador no debe resolver retenidos');
            } catch (HeldPaymentReviewException $e) {
                $this->assertSame('No tienes permiso para revisar cobros retenidos.', $e->getMessage());
            }
        }

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_PENDING, $held->status);
        $this->assertNull($held->resolved_by_user_id);
        $this->assertSame(0, Payment::withoutGlobalScopes()->where('idempotency_key', $held->idempotency_key)->count());
    }

    #[Test]
    public function aprobar_lleva_al_pago_el_dispositivo_el_gps_la_hora_offline_y_el_metodo(): void
    {
        $capturado = now()->subDays(20)->setTime(9, 15, 0);
        $held = $this->held([
            'payment_method' => 'transfer',
            'device_id' => 'tel-juan-01',
            'latitude' => '4.6097100',
            'longitude' => '-74.0817500',
            'offline_created_at' => $capturado,
        ]);

        $payment = app(HeldPaymentReviewService::class)->approve($held, $this->admin)->fresh();

        $this->assertSame('transfer', $payment->payment_method);
        $this->assertSame('tel-juan-01', $payment->device_id);
        $this->assertSame('4.6097100', $payment->latitude);
        $this->assertSame('-74.0817500', $payment->longitude);
        $this->assertSame($capturado->toDateTimeString(), $payment->offline_created_at->toDateTimeString());
    }

    #[Test]
    public function no_se_aprueba_si_el_credito_no_tiene_cuotas_pendientes(): void
    {
        Installment::query()->where('credit_id', $this->credit->id)->update(['status' => 'paid']);

        $this->assertNotNull(app(HeldPaymentReviewService::class)->approvalBlocker($this->held()));
    }

    #[Test]
    public function no_se_aprueba_si_el_capturador_ya_no_existe(): void
    {
        $held = $this->held();
        $this->collector->delete();

        $this->assertNull($held->fresh()->captured_by_user_id, 'la FK del capturador queda en null al borrar el usuario');
        $this->assertNotNull(app(HeldPaymentReviewService::class)->approvalBlocker($held->fresh()));
    }

    #[Test]
    public function rechazar_exige_motivo_y_no_crea_pago(): void
    {
        $held = $this->held();
        $service = app(HeldPaymentReviewService::class);

        try {
            $service->reject($held, $this->admin, '   ');
            $this->fail('Debió exigir el motivo');
        } catch (HeldPaymentReviewException) {
            // esperado
        }

        $service->reject($held, $this->admin, 'Ya se había cargado a mano (pago #123)');

        $held->refresh();
        $this->assertSame(HeldPayment::STATUS_REJECTED, $held->status);
        $this->assertSame('Ya se había cargado a mano (pago #123)', $held->resolution_notes);
        $this->assertDatabaseMissing('payments', ['idempotency_key' => $held->idempotency_key]);
    }

    #[Test]
    public function los_posibles_duplicados_son_pagos_del_mismo_credito_y_monto_cerca_de_la_fecha(): void
    {
        $held = $this->held(['payment_date' => now()->subDays(20)->toDateString()]);

        $cerca = app(PaymentSyncService::class)->applyToCredit($this->credit, [
            'amount' => 10_000, 'payment_date' => now()->subDays(18)->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, (string) Str::uuid());

        // Otro monto: no es duplicado.
        app(PaymentSyncService::class)->applyToCredit($this->credit->fresh(), [
            'amount' => 5_000, 'payment_date' => now()->subDays(19)->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, (string) Str::uuid());

        // Mismo monto pero anulado: el original anulado y su reversa no cuentan.
        $anulado = app(PaymentSyncService::class)->applyToCredit($this->credit->fresh(), [
            'amount' => 10_000, 'payment_date' => now()->subDays(21)->toDateString(), 'payment_method' => 'cash',
            'device_id' => null, 'offline_created_at' => null, 'latitude' => null, 'longitude' => null,
        ], $this->collector->id, (string) Str::uuid());
        app(PaymentManager::class)->reversePayment($anulado, $this->admin, 'Prueba de duplicados');

        $this->assertSame([$cerca->id], $held->possibleDuplicates()->pluck('id')->all());
    }
}
