<?php

declare(strict_types=1);

namespace App\Services\Metrics;

use App\Models\Credit;
use App\Models\Installment;
use App\Models\ParSnapshot;
use App\Services\Dashboard\DashboardCacheService;
use App\ValueObjects\Money;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

/**
 * Servicio de métricas de morosidad (delinquency).
 *
 * Implementa cálculos estándar de la industria de microfinanzas:
 * - PAR (Portfolio at Risk) en diferentes horizontes
 * - Aging de cartera
 * - Morosidad por collector
 *
 * DEFINICIONES:
 * - PAR (Portfolio at Risk): Porcentaje de la cartera con cuotas vencidas > N días
 *   PAR30 = (Balance de créditos con cuotas vencidas > 30 días) / (Cartera total activa) × 100
 *
 * - Aging: Distribución del saldo vencido por rangos de días de mora
 *
 * IMPORTANTE:
 * - Todos los queries excluyen parent credits.
 * - BALANCES se calculan como SUM(total_amount - amount_paid) de cuotas no pagadas,
 *   NO como SUM(principal_balance_after). Ver PortfolioMetricsService para explicación completa.
 */
class DelinquencyMetricsService
{
    /** Horizontes estándar para PAR */
    public const PAR_HORIZONS = [1, 7, 15, 30, 60, 90];

    public function __construct(
        private DashboardCacheService $cache
    ) {}

    // ─────────────────────────────────────────────────────────────────────
    // SCOPING POR COBRADOR (visibilidad supervisor→cobrador, issue #71)
    //
    // Todos los métodos públicos aceptan $collectorIds:
    //   null            → vista company-wide (admin / supervisor con sees_all).
    //                     NO altera las claves de caché existentes.
    //   array no vacío  → scope a esos collector_user_id (supervisor asignado).
    //   array vacío      → scope a "nadie" → métricas en cero (defensivo; los
    //                     callers deben cortocircuitar el caso 'unassigned').
    // El filtro siempre es sobre credits.collector_user_id.
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Normaliza el scope: null se mantiene (company-wide); un array vacío se
     * convierte en el centinela [0] (collector_user_id es siempre > 0, así que
     * no matchea nada y evita un `IN ()` inválido).
     *
     * @param  int[]|null  $collectorIds
     * @return int[]|null
     */
    private function normalizeScope(?array $collectorIds): ?array
    {
        if ($collectorIds === null) {
            return null;
        }

        return $collectorIds === []
            ? [0]
            : array_values(array_unique(array_map('intval', $collectorIds)));
    }

    /**
     * Sufijo estable para la clave de caché según el scope. Vacío para
     * company-wide (preserva las claves existentes); determinista para un scope.
     *
     * @param  int[]|null  $scope  Resultado de normalizeScope().
     */
    private function scopeSuffix(?array $scope): string
    {
        if ($scope === null) {
            return '';
        }

        $ids = $scope;
        sort($ids);

        return ':col:'.substr(md5(implode(',', $ids)), 0, 10);
    }

    /**
     * Calcula PAR (Portfolio at Risk) para múltiples horizontes.
     * Versión con caché. Para datos frescos sin caché, usar computeRawPAR().
     *
     * @return array<string, array{
     *   days: int,
     *   at_risk_balance: float,
     *   total_portfolio: float,
     *   par_percentage: float,
     *   credits_count: int
     * }>
     */
    public function getPARMetrics(int $companyId, ?array $collectorIds = null): array
    {
        $scope = $this->normalizeScope($collectorIds);

        return $this->cache->remember(
            $companyId,
            'delinquency.par'.$this->scopeSuffix($scope),
            fn () => $this->computeRawPAR($companyId, $collectorIds),
            'standard'
        );
    }

    /**
     * Calcula PAR directamente desde la DB, sin caché.
     *
     * Usado por el command credify:snapshot-par para obtener
     * los datos reales de fin de día sin depender del estado de la caché.
     *
     * @return array<string, array{
     *   days: int,
     *   at_risk_balance: float,
     *   total_portfolio: float,
     *   par_percentage: float,
     *   credits_count: int
     * }>
     */
    public function computeRawPAR(int $companyId, ?array $collectorIds = null): array
    {
        $today = Carbon::today();
        $scope = $this->normalizeScope($collectorIds);

        $totalPortfolio = Installment::where('company_id', $companyId)
            ->whereHas('credit', function ($q) use ($scope) {
                $q->leafCredits()->whereIn('status', Credit::ACTIVE_STATUSES);
                if ($scope !== null) {
                    $q->whereIn('collector_user_id', $scope);
                }
            })
            ->where('status', '!=', Installment::STATUS_PAID)
            ->selectRaw('COALESCE(SUM(total_amount - amount_paid), 0) as total')
            ->value('total');

        $totalPortfolioMoney = Money::of($totalPortfolio);

        $result = [];

        foreach (self::PAR_HORIZONS as $days) {
            $thresholdDate = $today->copy()->subDays($days);

            $atRiskCredits = Credit::where('company_id', $companyId)
                ->leafCredits()
                ->whereIn('status', Credit::ACTIVE_STATUSES)
                ->when($scope !== null, fn ($q) => $q->whereIn('collector_user_id', $scope))
                ->whereHas('installments', function ($q) use ($thresholdDate) {
                    $q->where('status', '!=', Installment::STATUS_PAID)
                        ->where('due_date', '<', $thresholdDate);
                })
                ->pluck('id');

            $atRiskBalance = Installment::where('company_id', $companyId)
                ->whereIn('credit_id', $atRiskCredits)
                ->where('status', '!=', Installment::STATUS_PAID)
                ->selectRaw('COALESCE(SUM(total_amount - amount_paid), 0) as total')
                ->value('total');

            $atRiskMoney = Money::of($atRiskBalance);

            $parPercentage = $totalPortfolioMoney->isZero()
                ? 0
                : round(($atRiskMoney->value() / $totalPortfolioMoney->value()) * 100, 2);

            $result["par{$days}"] = [
                'days' => $days,
                'at_risk_balance' => $atRiskMoney->value(),
                'total_portfolio' => $totalPortfolioMoney->value(),
                'par_percentage' => $parPercentage,
                'credits_count' => $atRiskCredits->count(),
            ];
        }

        return $result;
    }

    /**
     * PAR principal (PAR30) - métrica más común.
     */
    public function getPAR30(int $companyId): float
    {
        $par = $this->getPARMetrics($companyId);

        return $par['par30']['par_percentage'] ?? 0;
    }

    /**
     * Aging de cartera vencida (distribución por días de mora).
     *
     * @return array<string, array{
     *   label: string,
     *   min_days: int,
     *   max_days: int|null,
     *   overdue_balance: float,
     *   credits_count: int,
     *   percentage: float
     * }>
     */
    public function getAgingReport(int $companyId, ?array $collectorIds = null): array
    {
        $scope = $this->normalizeScope($collectorIds);

        return $this->cache->remember($companyId, 'delinquency.aging'.$this->scopeSuffix($scope), function () use ($companyId, $scope) {
            $today = Carbon::today()->toDateString();

            // Definir rangos de aging
            $ranges = [
                'current' => ['label' => 'Al día',    'min' => 0,  'max' => 0],
                '1_7' => ['label' => '1-7 días',  'min' => 1,  'max' => 7],
                '8_15' => ['label' => '8-15 días', 'min' => 8,  'max' => 15],
                '16_30' => ['label' => '16-30 días', 'min' => 16, 'max' => 30],
                '31_60' => ['label' => '31-60 días', 'min' => 31, 'max' => 60],
                '61_90' => ['label' => '61-90 días', 'min' => 61, 'max' => 90],
                '90_plus' => ['label' => '>90 días',  'min' => 91, 'max' => null],
            ];

            // Una sola query SQL agrupa por crédito:
            //   - max_days_overdue: máximo de (today - due_date) entre cuotas vencidas; 0 si ninguna está vencida.
            //   - total_balance: SUM exacto en decimal de (total_amount - amount_paid) de todas las cuotas no pagadas.
            // Usar aritmética SQL decimal evita la acumulación de errores de float PHP y garantiza que
            // SUM(balance de todos los buckets) == getActivePortfolioBalance() en todo momento.
            // Scope opcional por cobrador (supervisor asignado). Se añade al WHERE
            // con placeholders dinámicos; las bindings se anexan al final.
            $collectorSql = '';
            $collectorBindings = [];
            if ($scope !== null) {
                $collectorSql = ' AND c.collector_user_id IN ('.implode(',', array_fill(0, count($scope), '?')).')';
                $collectorBindings = $scope;
            }

            $rows = DB::select('
                SELECT
                    i.credit_id,
                    COALESCE(MAX(CASE WHEN i.due_date < ? THEN DATEDIFF(?, i.due_date) ELSE 0 END), 0) AS max_days_overdue,
                    COALESCE(SUM(i.total_amount - i.amount_paid), 0) AS total_balance
                FROM installments i
                INNER JOIN credits c ON c.id = i.credit_id
                WHERE i.company_id = ?
                  AND c.status IN (?, ?, ?)
                  AND NOT EXISTS (
                      SELECT 1 FROM credits ch WHERE ch.parent_credit_id = c.id
                  )
                  AND i.status != ?'.$collectorSql.'
                GROUP BY i.credit_id
            ', array_merge([
                $today, $today,
                $companyId,
                Credit::STATUS_ACTIVE, Credit::STATUS_DELAYED, Credit::STATUS_OVERDUE,
                Installment::STATUS_PAID,
            ], $collectorBindings));

            // Indexar por credit_id para clasificación rápida
            $credits = collect($rows)->keyBy('credit_id');

            // Clasificar cada crédito en su bucket y acumular saldos
            $result = [];
            $totalOverdue = Money::zero();

            foreach ($ranges as $key => $range) {
                $creditsInRange = $credits->filter(function ($row) use ($range) {
                    $days = (int) $row->max_days_overdue;

                    if ($range['min'] === 0 && $range['max'] === 0) {
                        return $days === 0;
                    }

                    if ($range['max'] === null) {
                        return $days >= $range['min'];
                    }

                    return $days >= $range['min'] && $days <= $range['max'];
                });

                // Sumar con BC Math para mantener precisión en el resultado final
                $rangeBalanceRaw = $creditsInRange->sum(fn ($r) => (float) $r->total_balance);
                $rangeBalance = Money::of($rangeBalanceRaw);

                if ($key !== 'current') {
                    $totalOverdue = $totalOverdue->add($rangeBalance);
                }

                $result[$key] = [
                    'label' => $range['label'],
                    'min_days' => $range['min'],
                    'max_days' => $range['max'],
                    'overdue_balance' => $rangeBalance->value(),
                    'credits_count' => $creditsInRange->count(),
                    'percentage' => 0, // Se calcula después
                ];
            }

            // Calcular porcentajes sobre el total vencido (buckets 1_7 … 90_plus)
            $totalOverdueValue = $totalOverdue->value();
            foreach ($result as $key => &$data) {
                if ($key !== 'current' && $totalOverdueValue > 0) {
                    $data['percentage'] = round(($data['overdue_balance'] / $totalOverdueValue) * 100, 2);
                }
            }

            return $result;
        }, 'frequent');  // Mismo TTL que portfolio.active_balance (5 min) para garantizar coherencia
    }

    /**
     * Top créditos en mora (para tabla de gestión).
     *
     * @return Collection<int, array{
     *   credit_id: int,
     *   client_name: string,
     *   client_id: int,
     *   collector_name: string|null,
     *   total_balance: float,
     *   overdue_balance: float,
     *   max_days_overdue: int,
     *   par_bucket: string,
     *   last_payment_date: string|null,
     *   status: string
     * }>
     */
    public function getTopOverdueCredits(int $companyId, int $limit = 20, ?array $collectorIds = null): Collection
    {
        return $this->getAllOverdueCredits($companyId, $collectorIds)->take($limit)->values();
    }

    /**
     * Todos los créditos en mora, ordenados por días de mora DESC.
     * Sin límite — usar para tabla paginada del admin.
     *
     * @return Collection<int, array{
     *   credit_id: int,
     *   client_name: string,
     *   client_id: int,
     *   collector_name: string|null,
     *   periodicity: string,
     *   total_balance: float,
     *   overdue_balance: float,
     *   max_days_overdue: int,
     *   overdue_installments_count: int,
     *   par_bucket: string,
     *   last_payment_date: string|null,
     *   status: string
     * }>
     */
    public function getAllOverdueCredits(int $companyId, ?array $collectorIds = null): Collection
    {
        $scope = $this->normalizeScope($collectorIds);

        return $this->cache->remember($companyId, 'delinquency.overdue_portfolio_v2'.$this->scopeSuffix($scope), function () use ($companyId, $scope) {
            $today = Carbon::today();

            // Incluye ACTIVE_STATUSES (no solo delayed/overdue) porque el status puede estar
            // desactualizado entre reconciliaciones nocturnas. La condición real de mora
            // se verifica directamente en las cuotas (whereHas + filtrado en PHP).
            $credits = Credit::where('company_id', $companyId)
                ->leafCredits()
                ->whereIn('status', Credit::ACTIVE_STATUSES)
                ->when($scope !== null, fn ($q) => $q->whereIn('collector_user_id', $scope))
                ->whereHas('installments', fn ($q) => $q
                    ->where('status', '!=', Installment::STATUS_PAID)
                    ->where('due_date', '<', $today->toDateString()))
                ->with([
                    'client:id,name',
                    'collector:id,name',
                    'installments',
                    'payments' => fn ($q) => $q->collections()->orderByDesc('payment_date'),
                ])
                ->get();

            $result = collect();

            foreach ($credits as $credit) {
                $unpaidInstallments = $credit->installments->filter(
                    fn (Installment $i) => $i->remaining_amount > 0
                );

                $overdueInstallments = $unpaidInstallments->filter(
                    fn (Installment $i) => $i->due_date->lt($today)
                );

                if ($overdueInstallments->isEmpty()) {
                    continue;
                }

                $totalBalance = $unpaidInstallments->sum('remaining_amount');

                if ($totalBalance <= 0) {
                    continue;
                }

                $overdueBalance = $overdueInstallments->sum('remaining_amount');

                $maxDaysOverdue = (int) $overdueInstallments
                    ->map(fn (Installment $i) => $i->due_date->diffInDays($today))
                    ->max();

                $lastPayment = $credit->payments->first();

                $result->push([
                    'credit_id' => $credit->id,
                    'client_name' => $credit->client?->name ?? 'N/A',
                    'client_id' => $credit->client_id,
                    'collector_name' => $credit->collector?->name,
                    'periodicity' => $credit->periodicity,
                    'total_balance' => Money::of($totalBalance)->value(),
                    'overdue_balance' => Money::of($overdueBalance)->value(),
                    'max_days_overdue' => $maxDaysOverdue,
                    'overdue_installments_count' => $overdueInstallments->count(),
                    'par_bucket' => match (true) {
                        $maxDaysOverdue > 90 => 'PAR 90+',
                        $maxDaysOverdue > 60 => 'PAR 61-90',
                        $maxDaysOverdue > 30 => 'PAR 31-60',
                        default => 'PAR 1-30',
                    },
                    'last_payment_date' => $lastPayment?->payment_date?->toDateString(),
                    'status' => $credit->status,
                ]);
            }

            return $result->sortByDesc('max_days_overdue')->values();
        }, 'standard');
    }

    /**
     * Morosidad por collector.
     *
     * @return Collection<int, array{
     *   collector_id: int,
     *   collector_name: string,
     *   total_credits: int,
     *   overdue_credits: int,
     *   delinquency_rate: float,
     *   overdue_balance: float,
     *   total_balance: float,
     *   par30: float
     * }>
     */
    public function getDelinquencyByCollector(int $companyId): Collection
    {
        return $this->cache->remember($companyId, 'delinquency.by_collector', function () use ($companyId) {
            $today = Carbon::today()->toDateString();
            $par30Threshold = Carbon::today()->subDays(30)->toDateString();

            // ── 1. Créditos activos con collector, agrupados ──────────────────
            // Una sola query SQL que calcula por collector_user_id:
            //   total_credits, overdue_credits (status in delayed/overdue),
            //   total_balance, overdue_balance y par30_credit_balance.
            //
            // par30_credit_balance: suma el saldo de las cuotas NO pagadas de
            // los créditos que tienen AL MENOS UNA cuota vencida > 30 días.
            // Se calcula con una sub-agregación sobre el credit_id.
            $rows = DB::select('
                SELECT
                    c.collector_user_id,
                    COUNT(DISTINCT c.id)                                                     AS total_credits,
                    COUNT(DISTINCT CASE WHEN c.status IN (?,?) THEN c.id END)                AS overdue_credits,
                    COALESCE(SUM(i.total_amount - i.amount_paid), 0)                         AS total_balance,
                    COALESCE(SUM(CASE WHEN i.due_date < ? THEN i.total_amount - i.amount_paid ELSE 0 END), 0) AS overdue_balance,
                    COALESCE(SUM(CASE WHEN par30.credit_id IS NOT NULL THEN i.total_amount - i.amount_paid ELSE 0 END), 0) AS par30_balance
                FROM credits c
                INNER JOIN installments i ON i.credit_id = c.id
                    AND i.status != ?
                LEFT JOIN (
                    SELECT DISTINCT credit_id
                    FROM installments
                    WHERE status != ? AND due_date < ?
                ) par30 ON par30.credit_id = c.id
                WHERE c.company_id = ?
                  AND c.status IN (?,?,?)
                  AND c.collector_user_id IS NOT NULL
                  AND NOT EXISTS (SELECT 1 FROM credits ch WHERE ch.parent_credit_id = c.id)
                GROUP BY c.collector_user_id
            ', [
                Credit::STATUS_DELAYED, Credit::STATUS_OVERDUE,          // overdue_credits CASE
                $today,                                                   // overdue_balance threshold
                Installment::STATUS_PAID,                                 // main join filter
                Installment::STATUS_PAID, $par30Threshold,                // par30 subquery
                $companyId,                                               // company filter
                Credit::STATUS_ACTIVE, Credit::STATUS_DELAYED, Credit::STATUS_OVERDUE,
            ]);

            if (empty($rows)) {
                return collect();
            }

            // ── 2. Nombres de collectors en una sola query ────────────────────
            $collectorIds = array_unique(array_column($rows, 'collector_user_id'));
            $collectors = DB::table('users')
                ->whereIn('id', $collectorIds)
                ->pluck('name', 'id');

            // ── 3. Construir resultado ────────────────────────────────────────
            $result = collect();

            foreach ($rows as $row) {
                $totalBalanceMoney = Money::of($row->total_balance);
                $par30 = $totalBalanceMoney->isZero()
                    ? 0
                    : round((Money::of($row->par30_balance)->value() / $totalBalanceMoney->value()) * 100, 2);

                $delinquencyRate = $row->total_credits > 0
                    ? round(($row->overdue_credits / $row->total_credits) * 100, 2)
                    : 0;

                $result->push([
                    'collector_id' => $row->collector_user_id,
                    'collector_name' => $collectors[$row->collector_user_id] ?? 'N/A',
                    'total_credits' => (int) $row->total_credits,
                    'overdue_credits' => (int) $row->overdue_credits,
                    'delinquency_rate' => $delinquencyRate,
                    'overdue_balance' => Money::of($row->overdue_balance)->value(),
                    'total_balance' => $totalBalanceMoney->value(),
                    'par30' => $par30,
                ]);
            }

            return $result->sortByDesc('delinquency_rate')->values();
        }, 'standard');
    }

    /**
     * Tendencia histórica real de PAR30 leída desde par_snapshots.
     *
     * Retorna los últimos $days días con snapshots reales.
     * Si hay menos de 2 snapshots reales, se marca is_partial_data = true
     * para que la UI muestre un banner de advertencia en lugar de silenciar el problema.
     *
     * @return array{
     *   is_partial_data: bool,
     *   snapshots_count: int,
     *   data: Collection<int, array{date: string, par1: float, par7: float, par15: float, par30: float, par60: float, par90: float}>
     * }
     */
    public function getPARTrend(int $companyId, int $days = 30, ?array $collectorIds = null): array
    {
        // Vista por cobrador (supervisor asignado): no hay snapshots históricos
        // por-cobrador (par_snapshots es company-wide). Para no filtrar datos de
        // toda la empresa, se devuelve el PAR EN VIVO del scope como punto único.
        if ($collectorIds !== null) {
            return $this->livePARTrendPoint($companyId, $collectorIds);
        }

        $snapshots = ParSnapshot::where('company_id', $companyId)
            ->lastDays($days)
            ->get(['snapshot_date', 'par1', 'par7', 'par15', 'par30', 'par60', 'par90']);

        $count = $snapshots->count();

        // Sin snapshots históricos: calcular el PAR actual en vivo para que el gráfico
        // nunca aparezca completamente vacío. El primer snapshot real se tomará esta noche.
        if ($count === 0) {
            return $this->livePARTrendPoint($companyId, null);
        }

        $data = $snapshots->map(fn (ParSnapshot $s) => [
            'date' => $s->snapshot_date->toDateString(),
            'par1' => (float) $s->par1,
            'par7' => (float) $s->par7,
            'par15' => (float) $s->par15,
            'par30' => (float) $s->par30,
            'par60' => (float) $s->par60,
            'par90' => (float) $s->par90,
        ])->values();

        return [
            'is_partial_data' => $count < 2,
            'snapshots_count' => $count,
            'data' => $data,
        ];
    }

    /**
     * Punto único de "tendencia" PAR calculado EN VIVO (sin snapshots históricos).
     * Se usa cuando no hay snapshots, o cuando la vista está scoped por cobrador
     * (no existen snapshots por-cobrador). is_partial_data = true para que la UI
     * muestre el banner de datos parciales.
     *
     * @param  int[]|null  $collectorIds
     */
    private function livePARTrendPoint(int $companyId, ?array $collectorIds): array
    {
        $par = $this->computeRawPAR($companyId, $collectorIds);

        return [
            'is_partial_data' => true,
            'snapshots_count' => 0,
            'data' => collect([[
                'date' => Carbon::today()->toDateString(),
                'par1' => (float) ($par['par1']['par_percentage'] ?? 0),
                'par7' => (float) ($par['par7']['par_percentage'] ?? 0),
                'par15' => (float) ($par['par15']['par_percentage'] ?? 0),
                'par30' => (float) ($par['par30']['par_percentage'] ?? 0),
                'par60' => (float) ($par['par60']['par_percentage'] ?? 0),
                'par90' => (float) ($par['par90']['par_percentage'] ?? 0),
            ]]),
        ];
    }

    /**
     * Créditos en mora dentro de un bucket de antigüedad específico.
     *
     * @param  string  $bucket  Clave del rango: '1_7'|'8_15'|'16_30'|'31_60'|'61_90'|'90_plus'
     * @return Collection<int, array{credit_id: int, client_name: string, client_id: int, collector_name: string|null, periodicity: string, total_balance: float, overdue_balance: float, max_days_overdue: int, overdue_installments_count: int, par_bucket: string, last_payment_date: string|null, status: string}>
     */
    public function getAgingBucketCredits(int $companyId, string $bucket, ?array $collectorIds = null): Collection
    {
        $ranges = [
            '1_7' => ['min' => 1,  'max' => 7],
            '8_15' => ['min' => 8,  'max' => 15],
            '16_30' => ['min' => 16, 'max' => 30],
            '31_60' => ['min' => 31, 'max' => 60],
            '61_90' => ['min' => 61, 'max' => 90],
            '90_plus' => ['min' => 91, 'max' => null],
        ];

        if (! isset($ranges[$bucket])) {
            return collect();
        }

        $range = $ranges[$bucket];

        return $this->getAllOverdueCredits($companyId, $collectorIds)
            ->filter(function (array $credit) use ($range): bool {
                $days = $credit['max_days_overdue'];

                return $range['max'] === null
                    ? $days >= $range['min']
                    : $days >= $range['min'] && $days <= $range['max'];
            })
            ->values();
    }

    /**
     * Tendencia de morosidad basada en datos SIMULADOS con rand().
     *
     * @deprecated Reemplazado por getPARTrend() que lee datos reales de par_snapshots.
     *             Conservado solo como referencia durante la transición.
     *             NO usar en producción — los datos son inventados.
     *
     * @return Collection<int, array{date: string, par30: float}>
     */
    public function getPARTrendLegacy(int $companyId, int $days = 30): Collection
    {
        return $this->cache->remember($companyId, "delinquency.par_trend_legacy_{$days}", function () use ($companyId, $days) {
            $result = collect();
            $currentPar = $this->getPAR30($companyId);
            $cursor = Carbon::now()->subDays($days - 1);

            while ($cursor->lessThanOrEqualTo(Carbon::now())) {
                $variation = $currentPar * (rand(-10, 10) / 100);
                $historicalPar = max(0, $currentPar + $variation);
                $result->push(['date' => $cursor->toDateString(), 'par30' => round($historicalPar, 2)]);
                $cursor->addDay();
            }

            $result->pop();
            $result->push(['date' => Carbon::now()->toDateString(), 'par30' => $currentPar]);

            return $result;
        }, 'slow');
    }

    /**
     * Resumen ejecutivo de morosidad para dashboard.
     */
    public function getDelinquencySummary(int $companyId, ?array $collectorIds = null): array
    {
        $scope = $this->normalizeScope($collectorIds);

        return $this->cache->remember($companyId, 'delinquency.summary'.$this->scopeSuffix($scope), function () use ($companyId, $collectorIds) {
            $par = $this->getPARMetrics($companyId, $collectorIds);
            $aging = $this->getAgingReport($companyId, $collectorIds);

            // Calcular total vencido
            $totalOverdue = Money::zero();
            foreach ($aging as $key => $data) {
                if ($key !== 'current') {
                    $totalOverdue = $totalOverdue->add($data['overdue_balance']);
                }
            }

            // Créditos en riesgo grave (>30 días)
            $severeRisk = ($aging['31_60']['credits_count'] ?? 0)
                + ($aging['61_90']['credits_count'] ?? 0)
                + ($aging['90_plus']['credits_count'] ?? 0);

            return [
                'par1' => $par['par1']['par_percentage'] ?? 0,
                'par7' => $par['par7']['par_percentage'] ?? 0,
                'par30' => $par['par30']['par_percentage'] ?? 0,
                'par60' => $par['par60']['par_percentage'] ?? 0,
                'par90' => $par['par90']['par_percentage'] ?? 0,
                'total_overdue_balance' => $totalOverdue->value(),
                'total_portfolio' => $par['par1']['total_portfolio'] ?? 0,
                'credits_at_risk_severe' => $severeRisk,
                'health_indicator' => $this->calculateHealthIndicator($par['par30']['par_percentage'] ?? 0),
            ];
        }, 'frequent');
    }

    /**
     * Calcula un indicador de salud basado en PAR30.
     *
     * @return string 'excellent'|'good'|'fair'|'poor'|'critical'
     */
    private function calculateHealthIndicator(float $par30): string
    {
        return match (true) {
            $par30 <= 2 => 'excellent',
            $par30 <= 5 => 'good',
            $par30 <= 10 => 'fair',
            $par30 <= 20 => 'poor',
            default => 'critical',
        };
    }
}
