<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\CompanyFinancialSettings;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\Sync\CapturerResolver;
use App\Support\SafeReport;
use App\Support\SimplifiedAmount;
use App\ValueObjects\Money;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

/**
 * Lógica de POST /api/pwa/sync/payments (la cola offline de cobros).
 *
 * Principio: un cobro capturado en campo nunca se pierde ni se rechaza en
 * silencio. Cada ítem termina en uno de estos estados:
 *   - success   → se aplicó al crédito.
 *   - duplicate → ya existía un pago con esa clave.
 *   - held      → no se podía aplicar con seguridad y quedó en `held_payments`
 *                 para que un admin decida. No toca saldos.
 *   - error     → solo si falta la clave de idempotencia o no es un UUID (sin
 *                 ella no hay forma de deduplicar), o por un fallo interno.
 * El teléfono suelta el ítem con success, duplicate o held. Cada resultado
 * trae `index` (su posición en el lote recibido) y la clave tal como llegó.
 */
class PaymentSyncService
{
    public function __construct(
        private readonly PaymentManager $paymentManager,
        private readonly CreditStatusSyncService $creditStatusSyncService,
        private readonly CapturerResolver $capturerResolver,
    ) {}

    /**
     * @param  array<int, mixed>  $payments  ítems crudos del lote (forma validada por SyncPaymentsRequest)
     * @param  string  $role  'collector' | 'supervisor' | 'admin'
     * @return array{results: list<array<string, mixed>>, summary: array{total: int, success: int, duplicates: int, held: int, errors: int}}
     */
    public function processBatch(array $payments, User $user, string $role): array
    {
        $results = [];
        $counts = ['success' => 0, 'duplicate' => 0, 'held' => 0, 'error' => 0];
        // Una vez por lote: la conversión de las filas en modo simplificado.
        $settings = CompanyFinancialSettings::forCompany($user->company_id);

        // `index` = posición en el lote recibido: el teléfono empareja por ahí el
        // resultado de un ítem cuya clave no puede reconocer (falta o no es UUID).
        foreach (array_values($payments) as $position => $raw) {
            $item = is_array($raw) ? $raw : [];

            try {
                $result = $this->processSingle($item, $user, $role, $settings);
            } catch (\Throwable $e) {
                // Sin getMessage(): el de un INSERT fallido trae el SQL con el
                // payload y el GPS. Tampoco se reporta la excepción tal cual
                // (ver SafeReport).
                Log::error('PaymentSyncService: unexpected error', [
                    'idempotency_key' => $item['idempotency_key'] ?? null,
                    'exception' => get_class($e),
                    'code' => $e->getCode(),
                    'user_id' => $user->id,
                ]);
                SafeReport::report($e);

                $result = [
                    'idempotency_key' => $item['idempotency_key'] ?? null,
                    'status' => 'error',
                    'error' => 'Error interno al procesar pago.',
                ];
            }

            $results[] = ['index' => $position] + $result;
            $counts[$result['status']]++;
        }

        return [
            'results' => $results,
            'summary' => [
                'total' => count($payments),
                'success' => $counts['success'],
                'duplicates' => $counts['duplicate'],
                'held' => $counts['held'],
                'errors' => $counts['error'],
            ],
        ];
    }

    /**
     * Registra un cobro de campo sobre un crédito ya validado. Lo usan el lote y
     * la aprobación de un retenido, para que los dos caminos dejen el pago igual.
     *
     * `$rethrowCloseFailure`: el lote lo deja en false (un fallo del cierre
     * automático se reporta y el pago sigue siendo success). La aprobación de
     * un retenido pasa true porque llama desde su propia transacción (ver el
     * catch del cierre).
     *
     * @param  array{amount: float, payment_date: string, payment_method: ?string, device_id: ?string, offline_created_at: Carbon|string|null, latitude: mixed, longitude: mixed}  $data
     */
    public function applyToCredit(Credit $credit, array $data, int $registeredByUserId, string $idempotencyKey, bool $rethrowCloseFailure = false): Payment
    {
        $payment = DB::transaction(function () use ($credit, $data, $registeredByUserId, $idempotencyKey) {
            $payment = $this->paymentManager->registerPayment(
                amount: (float) $data['amount'],
                paymentDate: Carbon::parse($data['payment_date']),
                registeredByUserId: $registeredByUserId,
                credit: $credit,
                paymentMethod: $data['payment_method'] ?? null,
            );

            $payment->idempotency_key = $idempotencyKey;
            $payment->device_id = $data['device_id'] ?? null;
            $payment->offline_created_at = $data['offline_created_at'] ?? null;
            $payment->latitude = $data['latitude'] ?? null;
            $payment->longitude = $data['longitude'] ?? null;
            $payment->save();

            return $payment;
        });

        // Igual que PaymentController: el cierre síncrono respeta la política de la empresa.
        // El pago ya está confirmado: si el cierre falla no se reporta como error
        // (el teléfono lo reenviaría); SyncCreditStatusJob, encolado por
        // PaymentRegistered, cierra el crédito después.
        try {
            $credit->refresh();
            $settings = CompanyFinancialSettings::forCompany($credit->company_id);
            if ((float) $credit->remaining_balance <= 0 && $settings->auto_close_on_full_payment) {
                $this->creditStatusSyncService->forceSync($credit);
                $credit->refresh();
            }
        } catch (\Throwable $e) {
            // Dentro de una transacción de quien llama, el "commit" de arriba fue
            // solo un savepoint: si la falla fue de la base (p. ej. un deadlock,
            // que en MySQL revierte la transacción entera, pago incluido), seguir
            // como si nada dejaría a quien llama trabajando sobre una transacción
            // muerta. Tiene que enterarse.
            if ($rethrowCloseFailure) {
                throw $e;
            }

            SafeReport::report($e);
        }

        return $payment;
    }

    /**
     * @param  array<string, mixed>  $item
     * @return array<string, mixed>
     */
    private function processSingle(array $item, User $user, string $role, CompanyFinancialSettings $settings): array
    {
        $key = $item['idempotency_key'] ?? null;

        // ── 1. Sin clave válida no se puede deduplicar ───────────────────────
        if (! is_string($key) || ! Str::isUuid($key)) {
            return [
                'idempotency_key' => $key,
                'status' => 'error',
                'error' => 'Clave de idempotencia inválida.',
            ];
        }

        // ── 2. Ya procesado ──────────────────────────────────────────────────
        $existing = Payment::query()
            ->where('company_id', $user->company_id)
            ->where('idempotency_key', $key)
            ->first();

        if ($existing) {
            return $this->duplicateResult($existing, $key);
        }

        $alreadyHeld = HeldPayment::query()
            ->withoutGlobalScopes()
            ->where('company_id', $user->company_id)
            ->where('idempotency_key', $key)
            ->first();

        if ($alreadyHeld) {
            return $this->heldResult($alreadyHeld, $key);
        }

        // ── 3. Monto real ────────────────────────────────────────────────────
        // Del 2026-02-05 al 2026-03-09 PaymentView encolaba el monto tal como se
        // tecleó en modo simplificado ('50' = $50.000) con is_simplified_amount.
        // Hoy convierte antes de encolar, pero esas filas pueden seguir en la cola.
        $received = $item;
        $item = SimplifiedAmount::toReal($item, 'amount', $settings);

        // ── 4. ¿Se puede aplicar con seguridad? ──────────────────────────────
        $capturer = $this->capturerResolver->resolve($item['captured_by_user_id'] ?? null, $user);
        [$reason, $detail, $credit] = $this->holdReason($item, $key, $user, $role, $capturer);

        if ($reason !== null || $credit === null) {
            // Quien revisa ve el monto ya convertido; lo que se tecleó queda a la
            // vista aquí y en `payload` (el ítem tal como llegó).
            if (SimplifiedAmount::applies($received, 'amount')) {
                $detail = trim(($detail ?? '').' (capturado en modo simplificado: '.$received['amount'].')');
            }

            return $this->hold($item, $received, $key, $user, $capturer, $reason ?? HeldPayment::REASON_INVALID, $detail);
        }

        // ── 5. Aplicar ───────────────────────────────────────────────────────
        try {
            $payment = $this->applyToCredit($credit, [
                'amount' => (float) $item['amount'],
                'payment_date' => (string) $item['payment_date'],
                'payment_method' => $item['payment_method'] ?? null,
                'device_id' => $item['device_id'] ?? null,
                'offline_created_at' => $this->localDateTime($item['created_at_local'] ?? null),
                'latitude' => $item['latitude'] ?? null,
                'longitude' => $item['longitude'] ?? null,
            ], $user->id, $key);
        } catch (QueryException $e) {
            // Race de idempotencia (índice único): otro request registró este pago
            // entre el SELECT de dedup y el INSERT. Es un duplicado, no un error.
            if ((int) ($e->errorInfo[1] ?? 0) === 1062) {
                $existing = Payment::query()
                    ->where('company_id', $user->company_id)
                    ->where('idempotency_key', $key)
                    ->first();

                if ($existing) {
                    return $this->duplicateResult($existing, $key);
                }
            }

            throw $e;
        }

        Log::info('PaymentSyncService: payment synced', [
            'payment_id' => $payment->id,
            'idempotency_key' => $key,
            'credit_id' => $credit->id,
            'amount' => $item['amount'],
            'user_id' => $user->id,
        ]);

        return [
            'idempotency_key' => $key,
            'status' => 'success',
            'payment_id' => $payment->id,
            'credit_new_status' => $credit->status,
            'credit_is_paid' => $credit->status === Credit::STATUS_PAID,
        ];
    }

    /**
     * Motivo por el que un ítem NO se aplica, en el orden de la spec.
     *
     * @param  array<string, mixed>  $item
     * @return array{0: ?string, 1: ?string, 2: ?Credit} [motivo, detalle, crédito aplicable]
     */
    private function holdReason(array $item, string $key, User $user, string $role, ?User $capturer): array
    {
        // Antes que el envío manual a revisión: hold() guarda el retenido a nombre
        // de quien sube cuando el capturador no se reconoce, y con motivo `manual`
        // se podría aprobar a nombre de quien no cobró. Con `invalid` no se aprueba.
        if ($capturer === null) {
            return [HeldPayment::REASON_INVALID, 'El usuario que capturó el cobro no pertenece a la empresa o ya no tiene rol en la app.', null];
        }

        if (($item['hold'] ?? false) === true) {
            return [HeldPayment::REASON_MANUAL, 'El cobrador lo envió a revisión desde el teléfono.', null];
        }

        if ($capturer->id !== $user->id) {
            return [HeldPayment::REASON_FOREIGN_USER, "Lo capturó {$capturer->name} y lo subió {$user->name} desde el mismo teléfono.", null];
        }

        $validator = Validator::make($item, [
            'credit_id' => ['required', 'integer'],
            // min:0.01 como StorePaymentRequest: con gt:0 un 0.001 se aplicaba como $0.00.
            'amount' => ['required', 'numeric', 'min:0.01'],
            'payment_method' => ['nullable', 'string', 'in:cash,transfer,mobile'],
            'payment_date' => ['required', 'date'],
            'created_at_local' => ['nullable', 'date'],
            'device_id' => ['nullable', 'string', 'max:255'],
            'latitude' => ['nullable', 'numeric', 'between:-90,90'],
            'longitude' => ['nullable', 'numeric', 'between:-180,180'],
        ]);

        if ($validator->fails()) {
            return [HeldPayment::REASON_INVALID, $validator->errors()->first(), null];
        }

        $paymentDate = Carbon::parse((string) $item['payment_date'])->startOfDay();
        if ($paymentDate->greaterThan(Carbon::today())) {
            return [HeldPayment::REASON_FUTURE_DATE, "Fecha de cobro {$paymentDate->toDateString()}, posterior a hoy.", null];
        }

        $limit = Carbon::today()->subDays(HeldPayment::STALE_AFTER_DAYS);
        $capturedAt = $this->localDateTime($item['created_at_local'] ?? null);
        if ($paymentDate->lessThan($limit) || ($capturedAt !== null && $capturedAt->lessThan($limit))) {
            return [HeldPayment::REASON_STALE, 'Llegó con más de '.HeldPayment::STALE_AFTER_DAYS.' días de atraso.', null];
        }

        $query = Credit::query()
            ->where('company_id', $user->company_id)
            ->activeLeafCredits();

        if ($role === 'collector') {
            $query->where('collector_user_id', $user->id);
        }

        $credit = $query->find((int) $item['credit_id']);
        $amount = (float) $item['amount'];

        $blocker = $this->applicationBlocker($credit, $amount);
        if ($blocker !== null) {
            return [$blocker[0], $blocker[1], null];
        }

        /** @var Credit $credit applicationBlocker() ya rechazó el crédito nulo. */

        // Posibles duplicados: el caso típico de la cola que no bajaba y el cobrador
        // volvió a cargar el cobro a mano, con otra clave. Para cualquier ítem, solo
        // el MISMO día: un crédito diario paga el mismo monto todos los días. Para
        // uno que pasó más de un día en la cola, además, ver backlogDuplicate().
        // `confirm_duplicate`: el envío directo le preguntó al cobrador (409) y dijo
        // que es otro cobro; el reenvío confirmado llegó por aquí porque perdió la
        // respuesta. Solo un booleano verdadero: el ítem no pasa por un FormRequest.
        // La confirmación cubre lo que el cobrador pudo ver: los pagos iguales
        // capturados ANTES que este ítem. Uno capturado después (otro reenvío
        // confirmado que se aplicó mientras este esperaba en la cola) sigue
        // reteniendo; sin la hora de captura no hay límite y no se salta nada.
        $confirmed = ($item['confirm_duplicate'] ?? false) === true;
        $sameDay = $this->sameDayDuplicate($credit, $amount, $paymentDate->toDateString(), $key, $confirmed ? $capturedAt : null);

        if ($sameDay) {
            return [
                HeldPayment::REASON_POSSIBLE_DUPLICATE,
                "Ya hay un pago de {$sameDay->amount} en este crédito el {$sameDay->payment_date->toDateString()} (pago #{$sameDay->id}).",
                null,
            ];
        }

        $backlogDuplicate = $this->backlogDuplicate($credit, $amount, $paymentDate, $capturedAt, $key, $user);
        if ($backlogDuplicate !== null) {
            return [HeldPayment::REASON_POSSIBLE_DUPLICATE, $backlogDuplicate, null];
        }

        return [null, null, $credit];
    }

    /**
     * Fuente única de "¿ya hay un cobro igual ese día en este crédito?": el mismo
     * monto, la misma fecha de cobro y otra clave (la misma clave es el mismo
     * cobro, y eso ya lo resolvió la búsqueda por clave). La usan el lote (lo
     * retiene) y el envío directo (pregunta antes de aplicarlo). Anulados y
     * reversas no cuentan: no fueron dinero recibido.
     *
     * `$capturedAfter`: solo cuentan los pagos capturados después de ese momento
     * (la hora del teléfono si vino de una cola, si no la de llegada). Es el
     * límite de una confirmación del cobrador en el lote.
     *
     * No bloquea el crédito: dos cobros iguales que llegan a la vez pueden pasar
     * los dos este chequeo (riesgo residual, igual que antes en el lote).
     */
    public function sameDayDuplicate(Credit $credit, float $amount, string $paymentDate, string $key, ?Carbon $capturedAfter = null): ?Payment
    {
        $query = Payment::query()
            ->where('company_id', $credit->company_id)
            ->where('credit_id', $credit->id)
            ->collections()
            ->where('amount', Money::of($amount)->toString())
            ->where('payment_date', $paymentDate)
            ->where(fn ($q) => $q->whereNull('idempotency_key')->orWhere('idempotency_key', '!=', $key));

        if ($capturedAfter !== null) {
            $query->whereRaw('COALESCE(offline_created_at, created_at) > ?', [
                $capturedAfter->copy()->setTimezone((string) config('app.timezone'))->toDateTimeString(),
            ]);
        }

        return $query->orderBy('id')->first();
    }

    /**
     * Un ítem que pasó más de un día en la cola (el backlog del día del
     * despliegue): el cobrador vio el saldo igual y pudo volver a cargarlo a mano
     * OTRO día, con otra clave. Se busca un pago igual, en la ventana de posibles
     * duplicados, cargado DESPUÉS de capturar este. Lo que subió en menos de un
     * día sigue solo con la regla del mismo día: un crédito diario paga el mismo
     * monto todos los días.
     *
     * "Cargado" es la hora de captura del otro pago: la de llegada al servidor
     * solo si no vino de una cola. Si no, las cuotas diarias de un mismo backlog,
     * que llegan juntas, se retendrían unas a otras.
     *
     * Devuelve el detalle para quien revisa, o null si no hay nada parecido.
     */
    private function backlogDuplicate(Credit $credit, float $amount, Carbon $paymentDate, ?Carbon $capturedAt, string $key, User $user): ?string
    {
        if ($capturedAt === null || ! $capturedAt->lessThan(now()->subHours(HeldPayment::BACKLOG_AFTER_HOURS))) {
            return null;
        }

        $later = Payment::query()
            ->where('company_id', $user->company_id)
            ->where('credit_id', $credit->id)
            ->collections()
            ->where('amount', Money::of($amount)->toString())
            ->whereBetween('payment_date', [
                $paymentDate->copy()->subDays(HeldPayment::DUPLICATE_WINDOW_DAYS)->toDateString(),
                $paymentDate->copy()->addDays(HeldPayment::DUPLICATE_WINDOW_DAYS)->toDateString(),
            ])
            ->where(fn ($q) => $q->where('offline_created_at', '>', $capturedAt)
                ->orWhere(fn ($q) => $q->whereNull('offline_created_at')->where('created_at', '>', $capturedAt)))
            ->where(fn ($q) => $q->whereNull('idempotency_key')->orWhere('idempotency_key', '!=', $key))
            ->orderBy('id')
            ->first();

        if ($later === null) {
            return null;
        }

        $days = (int) $capturedAt->diffInDays(now());
        $inQueue = $days === 1 ? '1 día' : "{$days} días";

        return "Tras {$inQueue} en la cola, ya hay un pago de {$later->amount} en este crédito del {$later->payment_date->toDateString()} (pago #{$later->id}), registrado después de capturar este.";
    }

    /**
     * Fuente única de "¿este monto se puede aplicar a este crédito?". La usan
     * el lote y la aprobación de un retenido. Devuelve null si se puede, o
     * [motivo, detalle] si no.
     *
     * @return array{0: string, 1: string}|null
     */
    public function applicationBlocker(?Credit $credit, float $amount): ?array
    {
        if ($credit === null) {
            return [HeldPayment::REASON_CREDIT_UNAVAILABLE, 'El crédito ya no está activo, fue reemplazado, cambió de cobrador o no existe.'];
        }

        // Mismo requisito que PaymentPolicyValidator (flujo interactivo): sin una
        // cuota pendiente el crédito está en un estado inconsistente y el pago no
        // se aplica a ciegas; se retiene para que un admin lo mire.
        $hasPendingInstallment = $credit->installments()
            ->whereIn('status', [Installment::STATUS_PENDING, Installment::STATUS_PARTIAL_PAID, Installment::STATUS_OVERDUE])
            ->exists();

        if (! $hasPendingInstallment) {
            return [HeldPayment::REASON_CREDIT_UNAVAILABLE, 'El crédito no tiene cuotas pendientes donde aplicar el pago.'];
        }

        // Comparación exacta con Money VO (BC Math); sin tolerancias mágicas.
        $amountMoney = Money::of($amount);
        $remaining = Money::of((float) $credit->remaining_balance);
        if ($amountMoney->greaterThan($remaining)) {
            return [HeldPayment::REASON_EXCEEDS_BALANCE, "Monto {$amountMoney->toString()} mayor que el saldo {$remaining->toString()}."];
        }

        return null;
    }

    /**
     * @param  array<string, mixed>  $item  con el monto ya convertido a real
     * @param  array<string, mixed>  $received  el ítem tal como llegó (va a `payload`)
     * @return array<string, mixed>
     */
    private function hold(array $item, array $received, string $key, User $syncer, ?User $capturer, string $reason, ?string $detail): array
    {
        // Entero estricto: con is_numeric + (int), un 12.5 se enlazaba al crédito 12.
        // filter_var() castea un bool a 0/1 antes de validar, por eso se descarta aparte.
        $rawCreditId = $item['credit_id'] ?? null;
        $parsedCreditId = is_bool($rawCreditId) ? false : filter_var($rawCreditId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        $creditId = $parsedCreditId !== false
            ? Credit::query()
                ->withoutGlobalScopes()
                ->where('company_id', $syncer->company_id)
                ->whereKey($parsedCreditId)
                ->value('id')
            : null;

        $method = $item['payment_method'] ?? null;

        try {
            $held = HeldPayment::create([
                'company_id' => $syncer->company_id,
                'idempotency_key' => $key,
                'credit_id' => $creditId,
                'captured_by_user_id' => ($capturer ?? $syncer)->id,
                'synced_by_user_id' => $syncer->id,
                // Un monto que no cabe en decimal(12,2) haría fallar el INSERT y el
                // cobro se perdería como `error`; uno de menos de un centavo se
                // guardaría como 0.00. Los dos se retienen sin monto (motivo `invalid`).
                'amount' => isset($item['amount']) && is_numeric($item['amount']) && (float) $item['amount'] >= 0.01 && (float) $item['amount'] <= 9_999_999_999.99 ? (float) $item['amount'] : null,
                'payment_method' => in_array($method, ['cash', 'transfer', 'mobile'], true) ? $method : null,
                'payment_date' => $this->dateOrNull($item['payment_date'] ?? null),
                'offline_created_at' => $this->localDateTime($item['created_at_local'] ?? null),
                'device_id' => is_string($item['device_id'] ?? null) ? Str::limit($item['device_id'], 255, '') : null,
                'latitude' => isset($item['latitude']) && is_numeric($item['latitude']) && abs((float) $item['latitude']) <= 90 ? $item['latitude'] : null,
                'longitude' => isset($item['longitude']) && is_numeric($item['longitude']) && abs((float) $item['longitude']) <= 180 ? $item['longitude'] : null,
                'reason' => $reason,
                'reason_detail' => $detail !== null ? Str::limit($detail, 255, '') : null,
                'payload' => $received,
                'status' => HeldPayment::STATUS_PENDING,
            ]);
        } catch (QueryException $e) {
            // Dos subidas simultáneas del mismo ítem: el índice único gana, es el mismo retenido.
            if ((int) ($e->errorInfo[1] ?? 0) !== 1062) {
                throw $e;
            }

            $held = HeldPayment::query()
                ->withoutGlobalScopes()
                ->where('company_id', $syncer->company_id)
                ->where('idempotency_key', $key)
                ->firstOrFail();
        }

        Log::warning('PaymentSyncService: payment held for review', [
            'held_payment_id' => $held->id,
            'idempotency_key' => $key,
            'reason' => $reason,
            'user_id' => $syncer->id,
        ]);

        return $this->heldResult($held, $key);
    }

    /**
     * `$key` es la clave tal como llegó: la base la compara sin distinguir
     * mayúsculas y el teléfono empareja por la que mandó, no por la guardada.
     *
     * @return array<string, mixed>
     */
    private function duplicateResult(Payment $payment, string $key): array
    {
        return [
            'idempotency_key' => $key,
            'status' => 'duplicate',
            'payment_id' => $payment->id,
            'message' => 'Pago ya registrado anteriormente.',
        ];
    }

    /**
     * `$key` es la clave tal como llegó (ver duplicateResult()).
     *
     * @return array<string, mixed>
     */
    private function heldResult(HeldPayment $held, string $key): array
    {
        return [
            'idempotency_key' => $key,
            'status' => 'held',
            'held_id' => $held->id,
            'reason' => $held->reason,
        ];
    }

    /** La hora del teléfono llega en ISO con zona; se guarda en la zona de la app. */
    private function localDateTime(mixed $value): ?Carbon
    {
        if (! is_string($value) || $value === '') {
            return null;
        }

        try {
            return Carbon::parse($value)->setTimezone((string) config('app.timezone'));
        } catch (\Throwable) {
            return null;
        }
    }

    private function dateOrNull(mixed $value): ?string
    {
        if (! is_string($value) || $value === '') {
            return null;
        }

        try {
            return Carbon::parse($value)->toDateString();
        } catch (\Throwable) {
            return null;
        }
    }
}
