<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\Pwa;

use App\Exceptions\PlanLimitExceededException;
use App\Http\Controllers\Api\Pwa\Traits\RoleAwareQueries;
use App\Http\Controllers\Controller;
use App\Http\Requests\Pwa\ReorderCollectorCreditsRequest;
use App\Http\Requests\Pwa\StoreCreditRequest;
use App\Models\CollectorCreditOrder;
use App\Models\Company;
use App\Models\Credit;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\CollectorCreditOrderService;
use App\Services\CreditOperationService;
use App\Services\DueDateService;
use App\Services\PlanLimitService;
use Carbon\Carbon;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class CreditController extends Controller
{
    use RoleAwareQueries;

    /**
     * GET /api/pwa/credits
     *
     * Lista creditos segun el rol del usuario.
     * - Collector: solo sus creditos asignados
     * - Supervisor/Admin: todos los creditos de la empresa
     *
     * Query params:
     * - status: filtrar por estado (active, delayed, overdue)
     * - collector_id: filtrar por cobrador (supervisor/admin only)
     */
    public function index(Request $request): JsonResponse
    {
        $role = $this->getUserPwaRole($request);

        $query = Credit::query();

        // Aplicar filtro de rol (company-scoped + collector filter si aplica)
        $this->applyCreditRoleFilter($query, $request);

        // Filtro por estado
        if ($status = $request->get('status')) {
            if (in_array($status, Credit::ACTIVE_STATUSES, true)) {
                $query->where('status', $status);
            }
        } else {
            // Por defecto solo estados activos
            $query->whereIn('status', Credit::ACTIVE_STATUSES);
        }

        // IMPORTANTE: Excluir créditos padre (con hijos derivados)
        $query->whereDoesntHave('children');

        // Filtro por collector (solo supervisor/admin), validado contra la empresa
        if ($this->canViewAllCredits($request)) {
            $this->applyCollectorIdFilter($query, $request, 'credits.collector_user_id');
        }

        // Eager load cliente
        // `formatted_address` recorre la relación `addresses`: sin cargarla aquí
        // serían N consultas, una por crédito del listado.
        $query->with(['client:id,name,phone,identification', 'client.addresses']);

        // Incluir collector para supervisor/admin
        if ($this->canViewAllCredits($request)) {
            $query->with(['collector:id,name']);
        }

        // Contadores optimizados con withCount (evita N+1)
        $this->withInstallmentCounts($query);

        // Sumas optimizadas para balance
        $this->withInstallmentSums($query);

        // Último pago válido (ofMany → 1 query extra por batch, no N+1)
        $query->with(['latestPayment']);

        // Para collectors, usar sort_order personalizado si existe
        if ($role === 'collector') {
            $user = $request->user();

            $hasOrderRows = CollectorCreditOrder::where('company_id', $user->company_id)
                ->where('user_id', $user->id)
                ->exists();

            if ($hasOrderRows) {
                $query->leftJoin('collector_credit_order as cco', function ($join) use ($user) {
                    $join->on('credits.id', '=', 'cco.credit_id')
                        ->where('cco.user_id', '=', $user->id)
                        ->where('cco.company_id', '=', $user->company_id);
                })
                    ->orderByRaw('cco.sort_order IS NULL ASC')
                    ->orderBy('cco.sort_order', 'asc')
                    ->orderBy('credits.created_at', 'desc')
                    ->select('credits.*');
            } else {
                $query->orderBy('created_at', 'desc');
            }
        } else {
            $query->orderBy('created_at', 'desc');
        }

        // Sin tope: la PWA es offline-first y el cobrador necesita el conjunto COMPLETO
        // de sus créditos para trabajar sin conexión (se sincroniza a IndexedDB). Un
        // `->limit(100)` fijo truncaba en silencio (un cobrador con 102 créditos veía
        // solo 100, sin señal en `meta.total`). El resultado ya está acotado por
        // rol/empresa/estado y, en la práctica, por los cupos de plan (max_active_credits).
        $credits = $query->get();

        $data = $credits->map(fn (Credit $credit) => $this->formatCreditForList($credit, $role));

        return response()->json([
            'data' => $data->values(),
            'meta' => [
                'total' => $data->count(),
                'role' => $role,
            ],
        ]);
    }

    /**
     * GET /api/pwa/credits/{id}
     *
     * Detalle de credito con todas sus cuotas.
     */
    public function show(Request $request, int $id): JsonResponse
    {
        $role = $this->getUserPwaRole($request);
        $user = $request->user();

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

        // Collectors solo pueden ver sus creditos
        if ($role === 'collector') {
            $query->where('collector_user_id', $user->id);
        }

        $credit = $query
            ->with([
                'client:id,name,phone,identification',
                // `formatted_address` recorre la relación `addresses`.
                'client.addresses',
                'collector:id,name',
                'installments' => fn ($q) => $q->orderBy('installment_number'),
            ])
            ->find($id);

        if (! $credit) {
            return response()->json([
                'message' => 'Credito no encontrado o no tienes acceso.',
            ], 404);
        }

        // Encontrar la proxima cuota pendiente para sugerir monto
        $nextPendingInstallment = $credit->installments
            ->whereIn('status', ['pending', 'partial_paid', 'overdue'])
            ->sortBy('due_date')
            ->first();

        $installments = $credit->installments->map(fn ($inst) => [
            'id' => $inst->id,
            'number' => $inst->installment_number,
            'due_date' => $inst->due_date->toDateString(),
            'status' => $inst->status,
            'status_label' => $inst->getStatusLabel(),
            'principal_amount' => round((float) $inst->principal_amount, 2),
            'interest_amount' => round((float) $inst->interest_amount, 2),
            'total_amount' => round((float) $inst->total_amount, 2),
            'amount_paid' => round((float) $inst->amount_paid, 2),
            'remaining' => round((float) $inst->remaining_amount, 2),
            'is_suggested' => $nextPendingInstallment?->id === $inst->id,
        ]);

        // Calcular desglose de saldo pendiente usando accessors del modelo Installment
        $principalPending = $credit->installments
            ->where('status', '!=', Installment::STATUS_PAID)
            ->sum('principal_pending');

        $interestPending = $credit->installments
            ->where('status', '!=', Installment::STATUS_PAID)
            ->sum('interest_pending');

        // Calcular resumen de cuotas
        $paidCount = $credit->installments->where('status', Installment::STATUS_PAID)->count();
        $overdueCount = $credit->installments->where('status', Installment::STATUS_OVERDUE)->count();
        $pendingCount = $credit->installments->count() - $paidCount;

        $response = [
            'id' => $credit->id,
            'code' => $credit->code,
            'company_credit_number' => $credit->company_credit_number,
            'client' => [
                'id' => $credit->client->id,
                'name' => $credit->client->name,
                'phone' => $credit->client->phone,
                'identification' => $credit->client->identification,
                // El cobrador arma su ruta con la dirección. Ya viajaba en cobros
                // y en el snapshot offline; faltaba justo en el listado de créditos.
                'address' => $credit->client->formatted_address,
            ],
            'status' => $credit->status,
            'status_label' => $credit->getStatusLabel(),
            'amount' => round((float) $credit->amount, 2),
            'interest_rate' => round((float) $credit->interest_rate, 2),
            'total_receivable' => round((float) $credit->total_receivable, 2),
            'total_paid' => round((float) $credit->total_paid, 2),
            'remaining_balance' => round((float) $credit->remaining_balance, 2),
            'periodicity' => $credit->periodicity,
            'frequency_display' => $this->getFrequencyDisplay($credit),
            'start_date' => $credit->start_date->toDateString(),
            'installments_count' => $credit->installments->count(),
            'installments' => $installments->values(),
            'suggested_payment' => $nextPendingInstallment
                ? round((float) $nextPendingInstallment->remaining_amount, 2)
                : 0,
            'can_receive_payment' => in_array($credit->status, Credit::ACTIVE_STATUSES)
                && $this->canRegisterPayments($request),
            // Nuevo: Desglose de saldo
            'balance_breakdown' => [
                'principal_pending' => round($principalPending, 2),
                'interest_pending' => round($interestPending, 2),
                'total_pending' => round((float) $credit->remaining_balance, 2),
            ],
            // Nuevo: Información de pagos
            'payment_info' => [
                'total_paid_to_date' => round((float) $credit->total_paid, 2),
                'installments_summary' => [
                    'paid' => $paidCount,
                    'pending' => $pendingCount,
                    'overdue' => $overdueCount,
                ],
                'next_installment' => $nextPendingInstallment ? [
                    'number' => $nextPendingInstallment->installment_number,
                    'due_date' => $nextPendingInstallment->due_date->toDateString(),
                    'amount' => round((float) $nextPendingInstallment->remaining_amount, 2),
                ] : null,
            ],
        ];

        // Incluir collector para supervisor/admin
        if ($this->canViewAllCredits($request) && $credit->collector) {
            $response['collector'] = [
                'id' => $credit->collector->id,
                'name' => $credit->collector->name,
            ];
        }

        return response()->json($response);
    }

    /**
     * POST /api/pwa/credits
     *
     * Crear un nuevo crédito.
     */
    public function store(StoreCreditRequest $request): JsonResponse
    {
        // Verificar permiso
        if (! $this->canCreateCredits($request)) {
            return response()->json([
                'message' => 'No tienes permiso para crear créditos.',
            ], 403);
        }

        $user = $request->user();
        $clave = $request->input('idempotency_key');

        // PWA-002: un reintento de un crédito que ya se creó (la respuesta se
        // perdió). Se devuelve el original ANTES del límite del plan: si no, ese
        // mismo crédito podría hacer rechazar su reintento por "cupo alcanzado".
        if (is_string($clave) && ($existente = $this->creditoConClave((int) $user->company_id, $clave))) {
            return $this->respuestaDuplicada($existente, $clave, $user, 'busqueda');
        }

        $company = Company::find($user->company_id);

        // Si no se especifica collector, asignar al usuario actual si es collector
        $collectorUserId = $request->collector_user_id;
        if (! $collectorUserId) {
            $role = $this->getUserPwaRole($request);
            if ($role === 'collector') {
                $collectorUserId = $user->id;
            }
        }

        try {
            $creditService = app(CreditOperationService::class);

            $creditData = [
                'company_id' => $user->company_id,
                'client_id' => $request->client_id,
                'collector_user_id' => $collectorUserId,
                'amount' => (float) $request->amount,
                'interest_rate' => (float) $request->interest_rate,
                'installments_count' => (int) $request->installments_count,
                'periodicity' => $request->periodicity,
                'start_date' => $request->start_date,
                'first_due_date_override' => $request->first_due_date_override,
                'due_day_1' => $request->due_day_1,
                'due_day_2' => $request->due_day_2,
                'idempotency_key' => $clave,
            ];

            // Límite del plan (créditos activos + volumen mensual): el chequeo y la
            // creación van juntos, bajo lock por empresa, para que dos syncs concurrentes
            // no rebasen el cupo (#169). Bloqueo estricto, igual que en el panel.
            $service = app(PlanLimitService::class);
            $credit = $company
                ? $service->runGuardedCreate($company, function () use ($service, $company, $request, $creditService, $creditData) {
                    $service->assertCanCreateCredit($company, (float) $request->amount);

                    return $creditService->createNewCredit($creditData);
                })
                : $creditService->createNewCredit($creditData);

            Log::info('PWA Credit: Created', [
                'credit_id' => $credit->id,
                'client_id' => $credit->client_id,
                'amount' => $credit->amount,
                'user_id' => $user->id,
            ]);

            // Agregar a la ruta del collector si se solicita
            if ($request->boolean('add_to_route') && $collectorUserId) {
                app(CollectorCreditOrderService::class)->appendCredit(
                    $user->company_id,
                    $collectorUserId,
                    $credit->id,
                );
            }

            return $this->respuestaCredito($credit, duplicado: false);

        } catch (PlanLimitExceededException $e) {
            // Otro envío con la misma clave pudo llenar el cupo mientras este
            // esperaba el lock: entonces el crédito existe y es este mismo.
            if (is_string($clave) && ($existente = $this->creditoConClave((int) $user->company_id, $clave))) {
                return $this->respuestaDuplicada($existente, $clave, $user, 'limite');
            }

            return response()->json(['message' => $e->getMessage()], 422);
        } catch (UniqueConstraintViolationException $e) {
            // Carrera: dos envíos con la misma clave pasaron la búsqueda. El insert
            // del crédito es lo primero que escribe este envío: el índice único lo
            // frena antes de las cuotas, el desembolso y la operación financiera, y
            // la transacción vuelve atrás. El crédito es el del otro envío.
            $existente = is_string($clave) ? $this->creditoConClave((int) $user->company_id, $clave) : null;
            if ($existente) {
                return $this->respuestaDuplicada($existente, $clave, $user, 'carrera');
            }

            return $this->errorAlCrearCredito($request, $e);
        } catch (\Exception $e) {
            return $this->errorAlCrearCredito($request, $e);
        }
    }

    private function creditoConClave(int $companyId, string $clave): ?Credit
    {
        return Credit::query()
            ->where('company_id', $companyId)
            ->where('idempotency_key', $clave)
            ->first();
    }

    /**
     * @param  string  $via  Por dónde se atrapó el duplicado: 'busqueda' (antes del
     *                       límite del plan), 'limite' (el límite lo rechazó porque el
     *                       otro envío llenó el cupo) o 'carrera' (índice único).
     */
    private function respuestaDuplicada(Credit $credit, string $clave, User $user, string $via): JsonResponse
    {
        Log::info('PWA Credit: Duplicate prevented', [
            'idempotency_key' => $clave,
            'existing_credit_id' => $credit->id,
            'company_id' => $credit->company_id,
            'user_id' => $user->id,
            'via' => $via,
        ]);

        return $this->respuestaCredito($credit, duplicado: true);
    }

    /** El mismo cuerpo para un crédito recién creado y para uno que ya existía (duplicado). */
    private function respuestaCredito(Credit $credit, bool $duplicado): JsonResponse
    {
        $credit->load(['client:id,name', 'installments']);

        return response()->json([
            'message' => $duplicado ? 'Este crédito ya estaba creado.' : 'Crédito creado correctamente.',
            'duplicate' => $duplicado,
            'credit' => [
                'id' => $credit->id,
                'code' => $credit->code,
                'company_credit_number' => $credit->company_credit_number,
                'client' => [
                    'id' => $credit->client->id,
                    'name' => $credit->client->name,
                ],
                'amount' => round((float) $credit->amount, 2),
                'interest_rate' => round((float) $credit->interest_rate, 2),
                'total_receivable' => round((float) $credit->total_receivable, 2),
                'installments_count' => $credit->installments->count(),
                'installment_amount' => round((float) $credit->installments->first()?->total_amount, 2),
                'periodicity' => $credit->periodicity,
                'start_date' => $credit->start_date->toDateString(),
                'first_due_date' => $credit->installments->first()?->due_date->toDateString(),
            ],
        ], $duplicado ? 200 : 201);
    }

    private function errorAlCrearCredito(StoreCreditRequest $request, \Throwable $e): JsonResponse
    {
        Log::error('PWA Credit: Error', [
            'client_id' => $request->client_id,
            'amount' => $request->amount,
            'user_id' => $request->user()->id,
            'error' => $e->getMessage(),
        ]);

        return response()->json([
            'message' => 'Error al crear el crédito. Intenta de nuevo.',
        ], 500);
    }

    /**
     * GET /api/pwa/credits/primera-cuota
     *
     * Cuándo vencería la primera cuota con estos datos, sin crear nada.
     *
     * Existe para que el formulario deje de adivinar. Calculaba la fecha en
     * JavaScript, con su propia versión de las reglas, y la mandaba como
     * `first_due_date_override` — que MANDA sobre el día elegido. Resultado: el
     * cobrador elegía "viernes" y la cuota caía otro día. Ahora pregunta, y
     * quien responde es el mismo servicio que construye el calendario.
     */
    public function primeraCuota(Request $request, DueDateService $fechas): JsonResponse
    {
        $datos = $request->validate([
            'start_date' => ['required', 'date'],
            'periodicity' => ['required', 'in:daily,weekly,biweekly,monthly'],
            // weekly: día ISO de la semana (1=lunes). monthly/biweekly: día del mes.
            'due_day_1' => ['nullable', 'integer', 'min:1', 'max:31'],
            'due_day_2' => ['nullable', 'integer', 'min:1', 'max:31'],
        ]);

        $primera = $fechas->firstDueDate(
            Carbon::parse($datos['start_date'])->startOfDay(),
            $datos['periodicity'],
            isset($datos['due_day_1']) ? (int) $datos['due_day_1'] : null,
            isset($datos['due_day_2']) ? (int) $datos['due_day_2'] : null,
        );

        return response()->json([
            'first_due_date' => $primera->toDateString(),
            'weekday_iso' => $primera->dayOfWeekIso,
            'weekday_label' => $fechas->getWeekdayNameIso($primera->dayOfWeekIso),
        ]);
    }

    /**
     * GET /api/pwa/credits/collectors
     *
     * Lista de cobradores disponibles para asignar a un crédito.
     */
    public function collectors(Request $request): JsonResponse
    {
        $user = $request->user();

        $collectors = User::query()
            ->where('company_id', $user->company_id)
            ->role(['collector', 'supervisor'])
            ->select('id', 'name')
            ->orderBy('name')
            ->get();

        return response()->json([
            'data' => $collectors->values(),
        ]);
    }

    /**
     * GET /api/pwa/credits/{id}/payments
     *
     * Historial de pagos de un crédito.
     */
    public function payments(Request $request, int $id): JsonResponse
    {
        $role = $this->getUserPwaRole($request);
        $user = $request->user();

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

        // Collectors solo pueden ver sus créditos
        if ($role === 'collector') {
            $query->where('collector_user_id', $user->id);
        }

        $credit = $query->find($id);

        if (! $credit) {
            return response()->json([
                'message' => 'Crédito no encontrado o no tienes acceso.',
            ], 404);
        }

        // Obtener pagos del crédito
        $payments = $credit->payments()
            ->collections()
            ->with(['registeredBy:id,name', 'installments'])
            ->orderBy('payment_date', 'desc')
            ->orderBy('created_at', 'desc')
            ->get();

        $data = $payments->map(fn (Payment $payment): array => [
            'id' => $payment->id,
            'amount' => round((float) $payment->amount, 2),
            'payment_date' => $payment->payment_date->toDateString(),
            'payment_method' => $payment->payment_method,
            'payment_method_label' => $this->getPaymentMethodLabel($payment->payment_method),
            'registered_by' => $payment->registeredBy?->name ?? 'Sistema',
            'registered_at' => $payment->created_at->toIso8601String(),
            // `->all()` y no la Collection: al serializar a JSON da exactamente la
            // misma lista, y evita anidar un tipo genérico dentro del array que
            // PHPStan no logra reconciliar consigo mismo.
            'installments_affected' => $payment->installments->map(fn (Installment $inst): array => [
                'number' => (int) $inst->installment_number,
                'amount_applied' => round((float) $inst->pivot->applied_amount, 2),
            ])->values()->all(),
        ]);

        return response()->json([
            'credit_id' => $credit->id,
            'data' => $data->values(),
            'total_count' => $data->count(),
            'total_amount' => round($payments->sum('amount'), 2),
        ]);
    }

    /**
     * GET /api/pwa/credits/order
     *
     * Devuelve la lista de créditos activos del collector en su orden personalizado.
     * Si no existe orden previo, lo inicializa (write-once).
     * Este endpoint es la fuente de verdad para la pantalla "Ordenar ruta".
     */
    public function order(Request $request): JsonResponse
    {
        $user = $request->user();
        $role = $this->getUserPwaRole($request);

        if ($role !== 'collector') {
            return response()->json(['message' => 'Solo disponible para cobradores.'], 403);
        }

        $service = app(CollectorCreditOrderService::class);
        $service->initializeForCollector($user->company_id, $user->id);

        $credits = Credit::query()
            ->where('credits.company_id', $user->company_id)
            ->where('credits.collector_user_id', $user->id)
            ->whereIn('credits.status', Credit::ACTIVE_STATUSES)
            ->whereDoesntHave('children')
            ->leftJoin('collector_credit_order as cco', function ($join) use ($user) {
                $join->on('credits.id', '=', 'cco.credit_id')
                    ->where('cco.user_id', '=', $user->id)
                    ->where('cco.company_id', '=', $user->company_id);
            })
            // `formatted_address` recorre la relación `addresses`: sin cargarla
            // aquí serían N consultas, una por crédito del listado.
            ->with(['client:id,name,phone,identification', 'client.addresses'])
            ->orderByRaw('cco.sort_order IS NULL ASC')
            ->orderBy('cco.sort_order', 'asc')
            ->orderBy('credits.created_at', 'desc')
            ->select('credits.*')
            ->get();

        return response()->json([
            'data' => $credits->map(fn (Credit $c) => [
                'id' => $c->id,
                'code' => $c->code,
                'company_credit_number' => $c->company_credit_number,
                'client' => [
                    'id' => $c->client->id,
                    'name' => $c->client->name,
                    'phone' => $c->client->phone,
                ],
                'status' => $c->status,
                'status_label' => $c->getStatusLabel(),
                'amount' => round((float) $c->amount, 2),
            ])->values(),
            'meta' => [
                'total' => $credits->count(),
            ],
        ]);
    }

    /**
     * PUT /api/pwa/credits/reorder
     *
     * Reordena los créditos del collector autenticado.
     * Requiere TODOS los credit_ids activos del collector, en el orden deseado.
     */
    public function reorder(ReorderCollectorCreditsRequest $request): JsonResponse
    {
        $user = $request->user();

        try {
            $service = app(CollectorCreditOrderService::class);
            $service->reorder(
                $user->company_id,
                $user->id,
                $request->input('credit_ids')
            );

            return response()->json([
                'message' => 'Orden actualizado correctamente.',
            ]);
        } catch (\InvalidArgumentException $e) {
            return response()->json([
                'message' => $e->getMessage(),
            ], 422);
        }
    }

    /**
     * Formatea un credito para el listado.
     */
    private function formatCreditForList(Credit $credit, string $role): array
    {
        // Usar valores precargados por withCount/withSum
        $totalReceivable = (float) ($credit->total_receivable ?? 0);
        $totalPaid = (float) ($credit->total_paid ?? 0);
        $remainingBalance = $totalReceivable - $totalPaid;

        // Calcular monto de cuota (total / numero de cuotas)
        $installmentsCount = (int) ($credit->total_installments_count ?? $credit->installments_count ?? 1);
        $installmentAmount = $installmentsCount > 0
            ? $totalReceivable / $installmentsCount
            : 0;

        $data = [
            'id' => $credit->id,
            'code' => $credit->code,
            'company_credit_number' => $credit->company_credit_number,
            'client' => [
                'id' => $credit->client->id,
                'name' => $credit->client->name,
                'phone' => $credit->client->phone,
                'identification' => $credit->client->identification,
                // El cobrador arma su recorrido con la dirección. Ya viajaba en
                // cobros y en el snapshot offline; este listado era el único que
                // no la mandaba.
                'address' => $credit->client->formatted_address,
            ],
            'status' => $credit->status,
            'status_label' => $credit->getStatusLabel(),
            'amount' => round((float) $credit->amount, 2),
            'remaining_balance' => round($remainingBalance, 2),
            'installment_amount' => round($installmentAmount, 2),
            'periodicity' => $credit->periodicity,
            'frequency_display' => $this->getFrequencyDisplay($credit),
            'installments_paid' => (int) ($credit->installments_paid_count ?? 0),
            'installments_pending' => (int) ($credit->installments_pending_count ?? 0),
            'total_installments' => $installmentsCount,
            'last_payment_date' => $credit->latestPayment?->payment_date?->toDateString(),
            'last_payment_amount' => $credit->latestPayment
                ? round((float) $credit->latestPayment->amount, 2)
                : null,
        ];

        // Incluir collector para supervisor/admin
        if (in_array($role, ['supervisor', 'admin'], true) && $credit->collector) {
            $data['collector'] = [
                'id' => $credit->collector->id,
                'name' => $credit->collector->name,
            ];
        }

        return $data;
    }

    /**
     * Obtiene label legible del método de pago.
     */
    private function getPaymentMethodLabel(?string $method): string
    {
        return match ($method) {
            'cash' => 'Efectivo',
            'transfer' => 'Transferencia',
            'mobile' => 'Pago móvil',
            default => 'No especificado',
        };
    }
}
