<?php

declare(strict_types=1);

namespace App\Services\Payments;

use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Payment;
use App\Models\User;
use App\Services\PaymentSyncService;
use App\Support\SafeReport;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Resolución de un cobro retenido por la cola offline.
 *
 * Aprobar aplica el cobro tal como se capturó (fecha, clave, cobrador) por el
 * mismo camino que el lote. Rechazar lo cierra con un motivo, sin tocar saldos.
 * Las dos cosas bloquean la fila: dos admins no pueden resolver el mismo retenido.
 */
class HeldPaymentReviewService
{
    /** Roles que pueden resolver un retenido (la bandeja de /admin). */
    private const REVIEWER_ROLES = ['admin', 'super_admin'];

    /** Nombre del índice único de payments.idempotency_key (MySQL lo nombra en el error 1062). */
    private const IDEMPOTENCY_INDEX = 'payments_idempotency_key_unique';

    public function __construct(private readonly PaymentSyncService $paymentSyncService) {}

    /** Por qué no se puede aprobar, o null si se puede. */
    public function approvalBlocker(HeldPayment $held): ?string
    {
        return $this->blockerFor($held, $this->creditFor($held));
    }

    /**
     * No llamarlo dentro de una transacción externa que ya leyó datos: con
     * REPEATABLE READ las lecturas simples usarían esa foto vieja y no la que
     * deja ver el bloqueo.
     */
    public function approve(HeldPayment $held, User $reviewer): Payment
    {
        $this->assertCanReview($held, $reviewer);

        // Reintentos ante un deadlock: todo se vuelve a leer bajo bloqueo, así que
        // repetir es seguro (si otro lo resolvió, el reintento lo ve y se niega).
        return DB::transaction(function () use ($held, $reviewer) {
            // Orden fijo: primero el retenido, después el crédito, antes de cualquier
            // otra lectura (el saldo y las cuotas se miran con el crédito bloqueado).
            $locked = $this->lock($held);
            $this->assertSameCompany($locked, $reviewer);
            $credit = $this->lockCredit($locked);

            $blocker = $this->blockerFor($locked, $credit);
            if ($blocker !== null) {
                throw new HeldPaymentReviewException($blocker);
            }

            /**
             * blockerFor() ya garantizó que los dos existen.
             *
             * @var Credit $credit
             * @var Carbon $paymentDate
             */
            $paymentDate = $locked->payment_date;

            try {
                $payment = $this->paymentSyncService->applyToCredit($credit, [
                    'amount' => (float) $locked->amount,
                    'payment_date' => $paymentDate->toDateString(),
                    'payment_method' => $locked->payment_method,
                    'device_id' => $locked->device_id,
                    'offline_created_at' => $locked->offline_created_at,
                    'latitude' => $locked->latitude,
                    'longitude' => $locked->longitude,
                ], (int) $locked->captured_by_user_id, $locked->idempotency_key, rethrowCloseFailure: true);
                // Dentro de esta transacción un fallo al cerrar el crédito debe subir:
                // si MySQL revirtió todo, no se puede marcar el retenido como aprobado.
            } catch (UniqueConstraintViolationException $e) {
                // Solo el índice de idempotencia significa que alguien lo aplicó entre
                // la revisión y el INSERT; cualquier otro choque es un error de verdad.
                if (! str_contains($e->getMessage(), self::IDEMPOTENCY_INDEX)) {
                    SafeReport::report($e);

                    // Ya reportado: la pantalla solo avisa. Sin encadenar (el SQL trae valores).
                    throw new HeldPaymentReviewException('No se pudo aplicar el cobro por un conflicto de datos. Quedó registrado para revisión técnica; recarga e inténtalo de nuevo.');
                }

                // Sin encadenar la excepción: su mensaje trae el SQL con valores.
                throw new HeldPaymentReviewException('Este cobro ya está aplicado.');
            }

            $locked->update([
                'status' => HeldPayment::STATUS_APPROVED,
                'resolved_by_user_id' => $reviewer->id,
                'resolved_at' => now(),
                'payment_id' => $payment->id,
            ]);

            return $payment;
        }, 3);
    }

    public function reject(HeldPayment $held, User $reviewer, string $notes): void
    {
        $this->assertCanReview($held, $reviewer);

        $notes = trim($notes);
        if ($notes === '') {
            throw new HeldPaymentReviewException('El motivo del rechazo es obligatorio.');
        }

        DB::transaction(function () use ($held, $reviewer, $notes) {
            $locked = $this->lock($held);

            $this->assertSameCompany($locked, $reviewer);

            if ($locked->status !== HeldPayment::STATUS_PENDING) {
                throw new HeldPaymentReviewException('Este cobro ya fue revisado.');
            }

            $locked->update([
                'status' => HeldPayment::STATUS_REJECTED,
                'resolved_by_user_id' => $reviewer->id,
                'resolved_at' => now(),
                'resolution_notes' => $notes,
            ]);
        }, 3);
    }

    /**
     * Las reglas de aprobación sobre un crédito ya cargado: approve() lo pasa
     * bloqueado, approvalBlocker() (la pantalla) lo lee sin bloquear.
     */
    private function blockerFor(HeldPayment $held, ?Credit $credit): ?string
    {
        if ($held->status !== HeldPayment::STATUS_PENDING) {
            return 'Este cobro ya fue revisado.';
        }

        if ($held->amount === null || $held->payment_date === null) {
            return 'Los datos del cobro llegaron incompletos o ilegibles: no se puede aplicar.';
        }

        // El lote también usa `invalid` con datos completos (p. ej. un capturador
        // que no es de la empresa): se bloquea igual, pero con su motivo real.
        if ($held->reason === HeldPayment::REASON_INVALID) {
            return 'Este cobro no se puede aplicar: '.($held->reason_detail ?? 'llegó con datos incompletos o ilegibles.');
        }

        if ($held->payment_date->greaterThan(Carbon::today())) {
            return "La fecha de cobro ({$held->payment_date->format('d/m/Y')}) todavía no llega: se podrá aprobar desde ese día, o recházalo.";
        }

        // Si se borró el usuario, la FK queda en null: no hay a nombre de quién registrarlo.
        if ($held->captured_by_user_id === null) {
            return 'El usuario que capturó el cobro ya no existe: no se puede registrar a su nombre. Regístralo a mano y rechaza este.';
        }

        // Defensa en profundidad: si el teléfono dijo quién cobró y no es a quien
        // quedó atribuido el retenido, no se adivina a nombre de quién registrarlo.
        if (! $held->capturerIsConfirmed()) {
            return 'No se pudo confirmar quién cobró este pago: regístralo a mano a nombre de quien corresponda y rechaza este.';
        }

        // payments.idempotency_key es único en toda la tabla: si ya existe, aprobar
        // chocaría con el índice. Pasa si otra pestaña aplicó el mismo cobro. El
        // número de pago solo se muestra si es de la misma empresa.
        $applied = Payment::query()
            ->withoutGlobalScopes()
            ->where('idempotency_key', $held->idempotency_key)
            ->first(['id', 'company_id']);
        if ($applied !== null) {
            return (int) $applied->company_id === $held->company_id
                ? "Este cobro ya está aplicado (pago #{$applied->id})."
                : 'Este cobro ya está aplicado.';
        }

        // creditFor() no filtra por cobrador (el admin puede aplicar a cualquier
        // crédito de su empresa): el texto del lote menciona el cambio de cobrador.
        if ($credit === null) {
            return 'El crédito ya no está activo, fue reemplazado o no existe. Si corresponde, regístralo a mano en el crédito vigente y rechaza este.';
        }

        // Las mismas reglas que usa el lote para decidir si aplicar: una sola fuente.
        $blocker = $this->paymentSyncService->applicationBlocker($credit, (float) $held->amount);
        if ($blocker !== null) {
            return $blocker[0] === HeldPayment::REASON_CREDIT_UNAVAILABLE
                ? $blocker[1].' Si corresponde, regístralo a mano en el crédito vigente y rechaza este.'
                : $blocker[1];
        }

        // Un reloj del teléfono mal puesto puede dar una fecha anterior al crédito.
        if ($credit->start_date !== null && $held->payment_date->lessThan($credit->start_date->copy()->startOfDay())) {
            return "La fecha de cobro ({$held->payment_date->format('d/m/Y')}) es anterior al inicio del crédito: revísala antes de aplicarlo.";
        }

        return null;
    }

    /**
     * Defensa en profundidad: la pantalla ya filtra por rol y por empresa, pero
     * lock() y creditFor() ignoran el scope global, así que el servicio no
     * confía en eso.
     */
    private function assertCanReview(HeldPayment $held, User $reviewer): void
    {
        if (! $reviewer->hasAnyRole(self::REVIEWER_ROLES)) {
            throw new HeldPaymentReviewException('No tienes permiso para revisar cobros retenidos.');
        }

        $this->assertSameCompany($held, $reviewer);
    }

    /** Se repite con la fila bloqueada: la que llegó de la pantalla puede estar vieja. */
    private function assertSameCompany(HeldPayment $held, User $reviewer): void
    {
        if (! $reviewer->isSuperAdmin() && $reviewer->company_id !== $held->company_id) {
            throw new HeldPaymentReviewException('No puedes revisar cobros de otra empresa.');
        }
    }

    private function lock(HeldPayment $held): HeldPayment
    {
        /** @var HeldPayment */
        return HeldPayment::query()
            ->withoutGlobalScopes()
            ->whereKey($held->id)
            ->lockForUpdate()
            ->firstOrFail();
    }

    /** El crédito vigente del retenido, sin bloquear (lo que muestra la pantalla). */
    private function creditFor(HeldPayment $held): ?Credit
    {
        if ($held->credit_id === null) {
            return null;
        }

        return Credit::query()
            ->withoutGlobalScopes()
            ->where('company_id', $held->company_id)
            ->activeLeafCredits()
            ->whereKey($held->credit_id)
            ->first();
    }

    /**
     * Lo mismo que creditFor(), pero bloqueando la fila. El bloqueo va por clave
     * primaria: con el filtro de vigencia (whereDoesntHave) en el mismo SELECT,
     * qué filas bloquea MySQL dependería del plan que elija. La vigencia se
     * decide después, sobre la fila ya bloqueada, con las mismas reglas que
     * scopeActiveLeafCredits().
     */
    private function lockCredit(HeldPayment $held): ?Credit
    {
        if ($held->credit_id === null) {
            return null;
        }

        $credit = Credit::query()
            ->withoutGlobalScopes()
            ->where('company_id', $held->company_id)
            ->whereKey($held->credit_id)
            ->lockForUpdate()
            ->first();

        if ($credit === null
            || ! in_array($credit->status, Credit::ACTIVE_STATUSES, true)
            || $credit->children()->exists()) {
            return null;
        }

        return $credit;
    }
}
