<?php

namespace App\Support;

use App\Enums\AccrualFrequency;
use App\Enums\InvestmentStatus;
use App\Enums\InvestmentTransactionType;
use App\Models\ForexImportStagingItem;
use App\Models\Investment;
use App\Models\InvestmentSetting;
use App\Models\InvestmentTransaction;
use App\Models\User;
use App\Services\ForexCsvImportService;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class InvestmentLedger
{
    public const ADMIN_CREDIT_TXID_PREFIX = 'ADMIN-ADJ-';

    /**
     * Tasa mensual vigente en administración (acreditaciones y proyecciones).
     */
    public static function currentMonthlyReturnPercent(): float
    {
        return (float) InvestmentSetting::current()->monthly_return;
    }

    /**
     * Etiqueta legible del % guardado en la transacción.
     *
     * - Diaria: {@see applied_percent} es la tasa diaria (mensual ÷ días del mes); se muestra el equivalente mensual.
     * - Por período: {@see applied_percent} es gain ÷ principal × 100 del rango (p. ej. 4% si se acreditó el 4% del mes).
     */
    public static function appliedPercentLabel(InvestmentTransaction $txn): ?string
    {
        if ($txn->applied_percent === null) {
            return null;
        }

        $pct = (float) $txn->applied_percent;

        if ($txn->type === InvestmentTransactionType::DailyGain) {
            if ($txn->accrual_on === null) {
                return sprintf('%s%% día', number_format($pct, 4, '.', ''));
            }

            $daysInMonth = max(1, Carbon::parse($txn->accrual_on)->daysInMonth);
            $monthlyEquiv = round($pct * $daysInMonth, 4);

            return sprintf('%s%% mes', number_format($monthlyEquiv, 2, '.', ''));
        }

        if ($txn->type === InvestmentTransactionType::PeriodAccrual
            || $txn->type === InvestmentTransactionType::MonthlyGain) {
            return sprintf('%s%% del período', number_format(round($pct, 4), 2, '.', ''));
        }

        return sprintf('%s%%', number_format(round($pct, 4), 2, '.', ''));
    }

    public static function isAdminAdjustmentInvestment(Investment $investment): bool
    {
        return str_starts_with((string) $investment->txid, self::ADMIN_CREDIT_TXID_PREFIX);
    }

    public static function referralBalanceForUser(int $userId): float
    {
        $earned = self::referralEarningsAtLevel($userId, 1) + self::referralEarningsAtLevel($userId, 2);

        $withdrawn = (float) InvestmentTransaction::query()
            ->where('beneficiary_user_id', $userId)
            ->where('type', InvestmentTransactionType::Withdrawal->value)
            ->sum('amount');

        return round(max(0, $earned - $withdrawn), 2);
    }

    /**
     * Saldo acumulado del libro consolidado del usuario tras cada movimiento visible (orden cronológico por id).
     *
     * @return array<int, float> transaction_id => balance_after
     */
    public static function runningBalanceAfterByLedgerTransactionId(int $userId): array
    {
        $rows = InvestmentTransaction::query()
            ->visibleInUserLedger($userId)
            ->orderBy('id')
            ->get(['id', 'type', 'amount']);

        $running = 0.0;
        $map = [];

        foreach ($rows as $row) {
            $amount = (float) $row->amount;
            $delta = $row->type === InvestmentTransactionType::Withdrawal ? -$amount : $amount;
            $running = round($running + $delta, 2);
            $map[(int) $row->id] = $running;
        }

        return $map;
    }

    /**
     * Saldo acumulado del libro consolidado inmediatamente antes de registrar un nuevo movimiento.
     */
    public static function currentUserLedgerRunningBalance(int $userId): float
    {
        $map = self::runningBalanceAfterByLedgerTransactionId($userId);

        if ($map === []) {
            return 0.0;
        }

        return (float) end($map);
    }

    public static function referralEarningsAtLevel(int $userId, int $level): float
    {
        $type = $level === 1
            ? InvestmentTransactionType::ReferralDirectBonus
            : InvestmentTransactionType::ReferralIndirectBonus;

        return self::sumReferralAmountsByTypes($userId, [$type]);
    }

    /**
     * Ganancias acumuladas hacia el tope L1/L2 (bonos pagados + ajustes manuales de progreso).
     */
    public static function referralProgressEarningsAtLevel(int $userId, int $level): float
    {
        $types = $level === 1
            ? [
                InvestmentTransactionType::ReferralDirectBonus,
                InvestmentTransactionType::ReferralManualDirectAdjustment,
            ]
            : [
                InvestmentTransactionType::ReferralIndirectBonus,
                InvestmentTransactionType::ReferralManualIndirectAdjustment,
            ];

        return self::sumReferralAmountsByTypes($userId, $types);
    }

    /**
     * @param  list<InvestmentTransactionType>  $types
     */
    private static function sumReferralAmountsByTypes(int $userId, array $types): float
    {
        return round((float) InvestmentTransaction::query()
            ->where('beneficiary_user_id', $userId)
            ->whereIn('type', array_map(static fn (InvestmentTransactionType $type): string => $type->value, $types))
            ->sum('amount'), 2);
    }

    /**
     * Detalle de la comisión aplicable al monto bruto (tope: deja al menos US$ 0,01 netos).
     *
     * @return array{
     *     gross: float,
     *     fee: float,
     *     net: float,
     *     fee_percent_component: float,
     *     fee_fixed_component: float,
     *     fee_before_cap: float,
     *     was_capped: bool
     * }
     */
    public static function withdrawalFeeBreakdownForGross(float $grossAmount, ?InvestmentSetting $settings = null): array
    {
        $settings ??= InvestmentSetting::current();
        $grossAmount = round($grossAmount, 2);
        $pctPart = round($grossAmount * ((float) $settings->withdrawal_fee_percent / 100), 2);
        $fixedPart = round((float) $settings->withdrawal_fee_fixed, 2);
        $rawFee = round($pctPart + $fixedPart, 2);
        $fee = min($rawFee, max(0, $grossAmount - 0.01));
        $net = round(max(0, $grossAmount - $fee), 2);
        $wasCapped = $rawFee > $fee + 0.0001;

        return [
            'gross' => $grossAmount,
            'fee' => $fee,
            'net' => $net,
            'fee_percent_component' => $pctPart,
            'fee_fixed_component' => $fixedPart,
            'fee_before_cap' => $rawFee,
            'was_capped' => $wasCapped,
        ];
    }

    /**
     * Comisión sobre el monto bruto solicitado: % + fijo. El retiro del ledger es el bruto.
     *
     * @return array{fee: float, net: float}
     */
    public static function withdrawalFeeAndNetForGross(float $grossAmount, ?InvestmentSetting $settings = null): array
    {
        $b = self::withdrawalFeeBreakdownForGross($grossAmount, $settings);

        return [
            'fee' => $b['fee'],
            'net' => $b['net'],
        ];
    }

    /** Si es modo mensual, indica si hoy coincide con el día de acreditación (ajusta meses cortos). */
    public static function isMonthlyAccrualDue(CarbonInterface $today, int $configuredDay): bool
    {
        $configuredDay = max(1, min(31, $configuredDay));
        $targetDay = min($configuredDay, $today->daysInMonth);

        return (int) $today->day === (int) $targetDay;
    }

    /**
     * Ejecuta la política configurada para la fecha dada (diaria cada día | mensual en el día pactado).
     *
     * @return int inversiones que recibieron acreditación nueva
     */
    public static function runAccrualForDate(CarbonInterface $on): int
    {
        $settings = InvestmentSetting::current();

        if (! self::isMonthlyAccrualDue(Carbon::parse($on)->startOfDay(), (int) $settings->accrual_day_of_month)) {
            return 0;
        }

        return self::applyMonthlyPeriodAccrualForDate($on);
    }

    public static function withdrawableFromInvestment(Investment $investment): float
    {
        if (! InvestmentStatusHelper::isActiveOrCompleted($investment)) {
            return 0.0;
        }

        $balance = (float) ($investment->capital_balance ?? $investment->amount);
        $principal = (float) $investment->amount;
        $maturityAt = $investment->maturity_at;

        if (InvestmentStatusHelper::isCompleted($investment)) {
            return round(max(0, $balance), 2);
        }

        if ($maturityAt === null) {
            return round(max(0, $balance), 2);
        }

        if (now()->lessThan($maturityAt)) {
            return round(max(0, $balance - $principal), 2);
        }

        return round(max(0, $balance), 2);
    }

    public static function totalWithdrawableForUser(int $userId): float
    {
        /** @var User|null $user */
        $user = User::query()->find($userId);
        if ($user !== null) {
            self::syncUnpooledReferralEarnings($user);
        }

        $sum = 0.0;
        Investment::query()
            ->where('user_id', $userId)
            ->whereIn('status', [InvestmentStatus::Active, InvestmentStatus::Completed])
            ->get()
            ->each(function (Investment $investment) use (&$sum): void {
                $sum += self::withdrawableFromInvestment($investment);
            });

        return round(max(0, $sum), 2);
    }

    /**
     * Asegura que las comisiones de referido registradas estén reflejadas en el contenedor retirable.
     * Idempotente: solo acredita la diferencia faltante (incluye retiros ya aplicados al contenedor).
     */
    public static function syncUnpooledReferralEarnings(User $user): void
    {
        $earned = round(
            self::referralEarningsAtLevel($user->id, 1) + self::referralEarningsAtLevel($user->id, 2),
            2,
        );

        if ($earned < 0.01) {
            return;
        }

        DB::transaction(function () use ($user, $earned): void {
            $container = self::ensureAdminCreditInvestment($user);

            $locked = Investment::query()
                ->whereKey($container->id)
                ->lockForUpdate()
                ->first();

            if ($locked === null || $locked->status !== InvestmentStatus::Active) {
                return;
            }

            $balance = round((float) ($locked->capital_balance ?? 0), 2);
            $adminNet = self::netAdminCreditsOnContainer($locked->id);
            $withdrawnFromContainer = round((float) InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::Withdrawal)
                ->sum('amount'), 2);

            $referralAllocated = round(min(
                $earned,
                max(0, $balance - $adminNet) + $withdrawnFromContainer,
            ), 2);

            $gap = round(max(0, $earned - $referralAllocated), 2);

            if ($gap < 0.01) {
                return;
            }

            $locked->update([
                'capital_balance' => round($balance + $gap, 2),
            ]);
        });
    }

    private static function netAdminCreditsOnContainer(int $containerInvestmentId): float
    {
        $credits = round((float) InvestmentTransaction::query()
            ->where('investment_id', $containerInvestmentId)
            ->where('type', InvestmentTransactionType::AdminAdjustment)
            ->whereNull('reversed_at')
            ->sum('amount'), 2);

        $reversals = round((float) InvestmentTransaction::query()
            ->where('investment_id', $containerInvestmentId)
            ->where('type', InvestmentTransactionType::AdminAdjustmentReversal)
            ->sum('amount'), 2);

        return round(max(0, $credits - $reversals), 2);
    }

    /**
     * Días de calendario (una por mes) en que la plataforma ejecutaría acreditación mensual, entre dos fechas inclusivas.
     *
     * @return array<int, Carbon>
     */
    public static function monthlyAccrualRunDaysBetween(
        CarbonInterface $fromInclusive,
        CarbonInterface $toInclusive,
        int $accrualConfiguredDay,
    ): array {
        $from = Carbon::parse($fromInclusive)->startOfDay();
        $to = Carbon::parse($toInclusive)->startOfDay();
        if ($from->gt($to)) {
            return [];
        }

        $accrualConfiguredDay = max(1, min(31, $accrualConfiguredDay));
        $out = [];
        $monthCursor = $from->copy()->startOfMonth();
        $endMonth = $to->copy()->startOfMonth();

        while ($monthCursor->lte($endMonth)) {
            $first = $monthCursor->copy()->startOfMonth();
            $safeDay = min($accrualConfiguredDay, $first->daysInMonth);
            $candidate = $first->copy()->day($safeDay)->startOfDay();
            if ($candidate->greaterThanOrEqualTo($from) && $candidate->lessThanOrEqualTo($to)) {
                $out[] = $candidate;
            }
            $monthCursor = $monthCursor->copy()->addMonth()->startOfMonth();
        }

        return $out;
    }

    /**
     * Misma fórmula que el bucle diario de {@see self::applyMonthlyPeriodAccrualForDate}: suma (principal × %mensual / días del mes) por día hábil del rango antes del vencimiento.
     */
    public static function sumPrincipalBasedDailyAccrualOverInclusiveRange(
        float $principal,
        float $monthlyPercent,
        CarbonInterface $periodStart,
        CarbonInterface $periodEndInclusive,
        CarbonInterface $confirmedSoD,
        CarbonInterface $maturitySoDExclusive,
    ): float {
        if ($principal <= 0 || $monthlyPercent <= 0) {
            return 0.0;
        }

        $sum = 0.0;
        $cursor = Carbon::parse($periodStart)->startOfDay();
        $cap = Carbon::parse($periodEndInclusive)->startOfDay();
        $confirmed = Carbon::parse($confirmedSoD)->startOfDay();
        $maturity = Carbon::parse($maturitySoDExclusive)->startOfDay();

        while ($cursor->lte($cap)) {
            if ($cursor->lt($confirmed)) {
                $cursor = $cursor->addDay();

                continue;
            }

            if (! $cursor->lt($maturity)) {
                break;
            }

            $dim = max(1, $cursor->daysInMonth);
            $sum += $principal * ($monthlyPercent / 100) / $dim;
            $cursor = $cursor->addDay();
        }

        return round($sum, 2);
    }

    /**
     * Tope de ganancia para un mes calendario (proporcional si el contrato no cubre todo el mes).
     */
    public static function monthlyGainCapForCalendarMonth(
        float $principal,
        float $monthlyPercent,
        CarbonInterface $calendarMonthStart,
        CarbonInterface $confirmedSoD,
        CarbonInterface $maturitySoDExclusive,
    ): float {
        if ($principal <= 0 || $monthlyPercent <= 0) {
            return 0.0;
        }

        $monthStart = Carbon::parse($calendarMonthStart)->startOfMonth()->startOfDay();
        $monthEnd = $monthStart->copy()->endOfMonth()->startOfDay();
        $confirmed = Carbon::parse($confirmedSoD)->startOfDay();
        $maturity = Carbon::parse($maturitySoDExclusive)->startOfDay();
        $lastActive = $maturity->copy()->subDay();

        $periodStart = $confirmed->gt($monthStart) ? $confirmed->copy() : $monthStart->copy();
        $periodEnd = $lastActive->lt($monthEnd) ? $lastActive->copy() : $monthEnd->copy();

        if ($periodStart->gt($periodEnd)) {
            return 0.0;
        }

        $eligibleDays = $periodStart->diffInDays($periodEnd) + 1;
        $daysInMonth = max(1, $monthStart->daysInMonth);

        return round($principal * ($monthlyPercent / 100) * ($eligibleDays / $daysInMonth), 2);
    }

    /**
     * Ganancia ya acreditada en un mes calendario (diaria + por período).
     */
    public static function creditedGainInCalendarMonth(int $investmentId, int $year, int $month): float
    {
        $monthStart = Carbon::create($year, $month, 1)->startOfDay();
        $monthEnd = $monthStart->copy()->endOfMonth();

        $daily = (float) InvestmentTransaction::query()
            ->where('investment_id', $investmentId)
            ->where('type', InvestmentTransactionType::DailyGain)
            ->whereDate('accrual_on', '>=', $monthStart->toDateString())
            ->whereDate('accrual_on', '<=', $monthEnd->toDateString())
            ->sum('amount');

        $period = (float) InvestmentTransaction::query()
            ->where('investment_id', $investmentId)
            ->where('type', InvestmentTransactionType::PeriodAccrual)
            ->whereYear('gain_period_start', $year)
            ->whereMonth('gain_period_start', $month)
            ->sum('amount');

        return round($daily + $period, 2);
    }

    /**
     * Ganancia ya acreditada en un mes calendario, excluyendo una transacción (p. ej. al recalcular).
     */
    public static function creditedGainInCalendarMonthExcludingTransaction(
        int $investmentId,
        int $year,
        int $month,
        ?int $excludeTransactionId = null,
    ): float {
        $total = self::creditedGainInCalendarMonth($investmentId, $year, $month);

        if ($excludeTransactionId === null) {
            return $total;
        }

        $excluded = InvestmentTransaction::query()->whereKey($excludeTransactionId)->first();
        if ($excluded === null || (int) $excluded->investment_id !== $investmentId) {
            return $total;
        }

        $subtract = 0.0;

        if ($excluded->type === InvestmentTransactionType::DailyGain && $excluded->accrual_on !== null) {
            $on = Carbon::parse($excluded->accrual_on);
            if ((int) $on->year === $year && (int) $on->month === $month) {
                $subtract = (float) $excluded->amount;
            }
        }

        if (in_array($excluded->type, [InvestmentTransactionType::PeriodAccrual, InvestmentTransactionType::MonthlyGain], true)
            && $excluded->gain_period_start !== null) {
            $start = Carbon::parse($excluded->gain_period_start);
            if ((int) $start->year === $year && (int) $start->month === $month) {
                $subtract = max($subtract, (float) $excluded->amount);
            }
        }

        return round(max(0, $total - $subtract), 2);
    }

    /**
     * Limita una ganancia propuesta al remanente del tope mensual del plan (% admin × principal).
     */
    public static function capProposedGainToMonthlyLimit(
        Investment $investment,
        float $principal,
        float $monthlyPercent,
        CarbonInterface $calendarMonthStart,
        float $proposedGain,
        ?float $alreadyCreditedInMonth = null,
    ): float {
        $proposedGain = round($proposedGain, 2);
        if ($proposedGain < 0.01) {
            return 0.0;
        }

        $confirmed = $investment->confirmed_at?->copy()->startOfDay();
        $maturity = $investment->maturity_at?->copy()->startOfDay();
        if ($confirmed === null || $maturity === null) {
            return $proposedGain;
        }

        $monthStart = Carbon::parse($calendarMonthStart)->startOfMonth();
        $cap = self::monthlyGainCapForCalendarMonth(
            $principal,
            $monthlyPercent,
            $monthStart,
            $confirmed,
            $maturity,
        );

        if ($cap <= 0) {
            return 0.0;
        }

        $already = $alreadyCreditedInMonth ?? self::creditedGainInCalendarMonth(
            $investment->id,
            (int) $monthStart->year,
            (int) $monthStart->month,
        );

        $remaining = round(max(0, $cap - $already), 2);
        if ($remaining < 0.01) {
            return 0.0;
        }

        return min($proposedGain, $remaining);
    }

    /**
     * Cupo calendario del mes de ejecución (sin catch-up de meses anteriores omitidos).
     *
     * Cada entrega liquida solo el mes calendario de {@see $runDay}. Los meses pactados
     * no entregados quedan como omitidos en el calendario contractual y se compensan
     * con meses de extensión al final del plan.
     *
     * @return array{
     *     period_start: Carbon,
     *     period_end_inclusive: Carbon,
     *     calendar_month_start: Carbon,
     * }|null
     */
    public static function resolveMonthlyAccrualCalendarPeriod(
        Investment $investment,
        CarbonInterface $runDay,
        ?CarbonInterface $accrualThrough = null,
    ): ?array {
        unset($accrualThrough);

        $run = Carbon::parse($runDay)->startOfDay();
        $confirmedSoD = $investment->confirmed_at?->copy()->startOfDay();
        $maturitySoD = $investment->maturity_at?->copy()->startOfDay();

        if ($confirmedSoD === null || $maturitySoD === null) {
            return null;
        }

        $lastActiveDay = $maturitySoD->copy()->subDay();
        if ($lastActiveDay->lt($confirmedSoD)) {
            return null;
        }

        $calendarMonthStart = $run->copy()->startOfMonth();
        $calendarMonthEnd = $calendarMonthStart->copy()->endOfMonth()->startOfDay();

        $accrualDay = max(1, min(31, (int) InvestmentSetting::current()->accrual_day_of_month));
        $dueDay = min($accrualDay, $calendarMonthStart->daysInMonth);
        $dueDate = $calendarMonthStart->copy()->day($dueDay);

        if ($run->lt($dueDate)) {
            return null;
        }

        if ($confirmedSoD->gt($calendarMonthEnd) || $lastActiveDay->lt($calendarMonthStart)) {
            return null;
        }

        $periodStart = $confirmedSoD->gt($calendarMonthStart)
            ? $confirmedSoD->copy()
            : $calendarMonthStart->copy();
        $periodEndInclusive = $calendarMonthEnd->gt($lastActiveDay)
            ? $lastActiveDay->copy()
            : $calendarMonthEnd->copy();

        if ($periodStart->gt($periodEndInclusive)) {
            return null;
        }

        return [
            'period_start' => $periodStart,
            'period_end_inclusive' => $periodEndInclusive,
            'calendar_month_start' => $calendarMonthStart,
        ];
    }

    /**
     * Aplica acreditación de periodo mensual a una sola inversión (misma lógica que el job global).
     */
    public static function applyMonthlyPeriodAccrualForInvestmentId(int $investmentId, CarbonInterface $runDay): bool
    {
        $run = Carbon::parse($runDay)->startOfDay();

        return (bool) DB::transaction(function () use ($investmentId, $run): bool {
            $locked = Investment::query()
                ->whereKey($investmentId)
                ->lockForUpdate()
                ->first();

            if ($locked === null || $locked->status !== InvestmentStatus::Active) {
                return false;
            }

            $confirmedAt = $locked->confirmed_at;
            $maturityAt = $locked->maturity_at;

            if ($confirmedAt === null || $maturityAt === null) {
                return false;
            }

            $hasDeposit = InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::Deposit)
                ->exists();

            if (! $hasDeposit) {
                return false;
            }

            $dup = InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::PeriodAccrual)
                ->whereDate('accrual_on', $run->toDateString())
                ->exists();

            if ($dup) {
                return false;
            }

            $confirmedSoD = $confirmedAt->copy()->startOfDay();
            $maturitySoD = $maturityAt->copy()->startOfDay();

            $through = $locked->accrual_through_date !== null
                ? Carbon::parse($locked->accrual_through_date)->startOfDay()
                : null;

            $window = self::resolveMonthlyAccrualCalendarPeriod($locked, $run, $through);
            if ($window === null) {
                return false;
            }

            $periodStart = $window['period_start'];
            $periodEndInclusive = $window['period_end_inclusive'];
            $calendarMonthStart = $window['calendar_month_start'];

            $monthAlreadyCredited = InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::PeriodAccrual)
                ->whereYear('gain_period_start', $calendarMonthStart->year)
                ->whereMonth('gain_period_start', $calendarMonthStart->month)
                ->exists();

            if ($monthAlreadyCredited) {
                return false;
            }

            $principal = (float) $locked->amount;
            $monthlyPct = self::currentMonthlyReturnPercent();

            if ($principal <= 0 || $monthlyPct <= 0) {
                return false;
            }

            $gain = self::sumPrincipalBasedDailyAccrualOverInclusiveRange(
                $principal,
                $monthlyPct,
                $periodStart,
                $periodEndInclusive,
                $confirmedSoD,
                $maturitySoD,
            );

            $gain = self::capProposedGainToMonthlyLimit(
                $locked,
                $principal,
                $monthlyPct,
                $calendarMonthStart,
                $gain,
            );

            if ($gain < 0.01) {
                return false;
            }

            $base = (float) ($locked->capital_balance ?? $locked->amount);
            $newBalance = round($base + $gain, 2);

            $periodTxn = InvestmentTransaction::query()->create([
                'investment_id' => $locked->id,
                'type' => InvestmentTransactionType::PeriodAccrual,
                'amount' => $gain,
                'balance_after' => $newBalance,
                'platform_month_accrual_id' => null,
                'investment_month_index' => null,
                'applied_percent' => $principal > 0 ? round($gain / $principal * 100, 8) : null,
                'accrual_on' => $run->toDateString(),
                'gain_period_start' => $periodStart->toDateString(),
                'gain_period_end' => $periodEndInclusive->toDateString(),
                'withdrawal_request_id' => null,
            ]);

            $locked->update([
                'capital_balance' => $newBalance,
                'accrual_through_date' => $periodEndInclusive->toDateString(),
            ]);

            self::syncInvestmentContractProgress($locked->fresh());

            return true;
        });
    }

    /**
     * Anula una ejecución completa de acreditación automática (solo emergencias / modo debug).
     *
     * Elimina todas las ganancias registradas en la fecha de entrega y las comisiones de referidos
     * generadas por esas ganancias.
     *
     * @param  'daily'|'period'  $mode
     * @return array{gain_count: int, referral_count: int}
     *
     * @throws ValidationException
     */
    public static function voidAccrualRun(CarbonInterface $runDay, string $mode): array
    {
        if (! in_array($mode, ['daily', 'period'], true)) {
            throw ValidationException::withMessages([
                'mode' => ['Modo de acreditación no válido.'],
            ]);
        }

        $run = Carbon::parse($runDay)->startOfDay();
        $type = $mode === 'daily'
            ? InvestmentTransactionType::DailyGain
            : InvestmentTransactionType::PeriodAccrual;

        return DB::transaction(function () use ($run, $type): array {
            $gainIds = InvestmentTransaction::query()
                ->whereNull('beneficiary_user_id')
                ->where('type', $type)
                ->whereDate('accrual_on', $run->toDateString())
                ->pluck('id');

            if ($gainIds->isEmpty()) {
                throw ValidationException::withMessages([
                    'run' => ['No hay acreditaciones registradas para anular en esta fecha y modo.'],
                ]);
            }

            $referrals = InvestmentTransaction::query()
                ->whereIn('referral_source_transaction_id', $gainIds)
                ->get(['beneficiary_user_id', 'amount']);

            foreach ($referrals as $referral) {
                if ($referral->beneficiary_user_id === null) {
                    continue;
                }

                self::debitReferralBonusFromWithdrawablePool(
                    (int) $referral->beneficiary_user_id,
                    (float) $referral->amount,
                );
            }

            $referralCount = InvestmentTransaction::query()
                ->whereIn('referral_source_transaction_id', $gainIds)
                ->delete();

            $investmentIds = InvestmentTransaction::query()
                ->whereIn('id', $gainIds)
                ->pluck('investment_id')
                ->unique()
                ->values();

            $gainCount = InvestmentTransaction::query()
                ->whereIn('id', $gainIds)
                ->delete();

            foreach ($investmentIds as $investmentId) {
                $locked = Investment::query()
                    ->whereKey($investmentId)
                    ->lockForUpdate()
                    ->first();

                if ($locked === null || self::isAdminAdjustmentInvestment($locked)) {
                    continue;
                }

                self::rebuildInvestmentCapitalBalance($locked);
                self::rebuildAccrualThroughDate($locked);
                self::syncInvestmentContractProgress($locked->fresh());
            }

            return [
                'gain_count' => $gainCount,
                'referral_count' => $referralCount,
            ];
        });
    }

    private static function rebuildInvestmentCapitalBalance(Investment $investment): void
    {
        $principal = (float) $investment->amount;

        $gains = (float) InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->whereIn('type', [
                InvestmentTransactionType::DailyGain->value,
                InvestmentTransactionType::PeriodAccrual->value,
                InvestmentTransactionType::MonthlyGain->value,
            ])
            ->sum('amount');

        $withdrawn = (float) InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->where('type', InvestmentTransactionType::Withdrawal->value)
            ->sum('amount');

        $investment->update([
            'capital_balance' => round(max(0, $principal + $gains - $withdrawn), 2),
        ]);
    }

    private static function rebuildAccrualThroughDate(Investment $investment): void
    {
        $maxDaily = InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->where('type', InvestmentTransactionType::DailyGain)
            ->max('accrual_on');

        $maxPeriodEnd = InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->where('type', InvestmentTransactionType::PeriodAccrual)
            ->max('gain_period_end');

        $candidates = array_filter([$maxDaily, $maxPeriodEnd]);
        $through = $candidates === []
            ? null
            : max($candidates);

        $investment->update([
            'accrual_through_date' => $through,
        ]);
    }

    /**
     * Valores correctos de una acreditación por período según fecha de ejecución y % pactado.
     *
     * @return array{
     *     period_start: CarbonInterface,
     *     period_end_inclusive: CarbonInterface,
     *     calendar_month_start: CarbonInterface,
     *     gain: float,
     *     applied_percent: float|null,
     *     plan_percent: float,
     * }|null
     */
    public static function correctPeriodAccrualForRunDay(
        Investment $investment,
        CarbonInterface $runDay,
        ?int $excludeTransactionId = null,
        ?float $monthlyPercentOverride = null,
    ): ?array {
        if ($investment->confirmed_at === null || $investment->maturity_at === null) {
            return null;
        }

        $window = self::resolveMonthlyAccrualCalendarPeriod($investment, $runDay);
        if ($window === null) {
            return null;
        }

        $principal = (float) $investment->amount;
        $planPercent = $monthlyPercentOverride ?? (float) $investment->monthly_return_snapshot;
        if ($planPercent <= 0) {
            $planPercent = self::currentMonthlyReturnPercent();
        }

        if ($principal <= 0 || $planPercent <= 0) {
            return null;
        }

        $periodStart = $window['period_start'];
        $periodEndInclusive = $window['period_end_inclusive'];
        $calendarMonthStart = $window['calendar_month_start'];

        $gain = self::sumPrincipalBasedDailyAccrualOverInclusiveRange(
            $principal,
            $planPercent,
            $periodStart,
            $periodEndInclusive,
            $investment->confirmed_at->copy()->startOfDay(),
            $investment->maturity_at->copy()->startOfDay(),
        );

        $gain = self::capProposedGainToMonthlyLimit(
            $investment,
            $principal,
            $planPercent,
            $calendarMonthStart,
            $gain,
            self::creditedGainInCalendarMonthExcludingTransaction(
                (int) $investment->id,
                (int) $calendarMonthStart->year,
                (int) $calendarMonthStart->month,
                $excludeTransactionId,
            ),
        );

        if ($gain < 0.01) {
            return null;
        }

        return [
            'period_start' => $periodStart,
            'period_end_inclusive' => $periodEndInclusive,
            'calendar_month_start' => $calendarMonthStart,
            'gain' => $gain,
            'applied_percent' => round($gain / $principal * 100, 8),
            'plan_percent' => $planPercent,
        ];
    }

    /**
     * Acreditación por período con fechas correctas y ganancia según la tasa mensual vigente en admin
     * (distinta al % pactado del contrato). No es un error de importación ni de rango.
     */
    public static function periodAccrualIsIntentionalAdminRateExecution(
        InvestmentTransaction $transaction,
    ): bool {
        if ($transaction->type !== InvestmentTransactionType::PeriodAccrual
            || $transaction->accrual_on === null
            || $transaction->investment === null) {
            return false;
        }

        $investment = $transaction->investment;
        $runDay = $transaction->accrual_on->copy()->startOfDay();
        $contractPercent = (float) $investment->monthly_return_snapshot;
        $adminPercent = self::currentMonthlyReturnPercent();

        if ($contractPercent <= 0 || $adminPercent <= 0) {
            return false;
        }

        if (abs($contractPercent - $adminPercent) < 0.0001) {
            return false;
        }

        $contractCorrect = self::correctPeriodAccrualForRunDay(
            $investment,
            $runDay,
            (int) $transaction->id,
        );

        if ($contractCorrect === null) {
            return false;
        }

        $txnPeriodStart = $transaction->gain_period_start?->toDateString();
        $txnPeriodEnd = $transaction->gain_period_end?->toDateString();
        $correctPeriodStart = $contractCorrect['period_start']->toDateString();
        $correctPeriodEnd = $contractCorrect['period_end_inclusive']->toDateString();

        if ($txnPeriodStart !== $correctPeriodStart || $txnPeriodEnd !== $correctPeriodEnd) {
            return false;
        }

        $adminCorrect = self::correctPeriodAccrualForRunDay(
            $investment,
            $runDay,
            (int) $transaction->id,
            $adminPercent,
        );

        if ($adminCorrect === null) {
            return false;
        }

        $currentGain = round((float) $transaction->amount, 2);
        $adminGain = round($adminCorrect['gain'], 2);

        return abs($currentGain - $adminGain) < 0.01;
    }

    /**
     * @return array{
     *     balances: array<int, float>,
     *     final_capital: float,
     * }
     */
    public static function replayInvestmentTransactionBalances(
        Investment $investment,
        ?int $overrideTransactionId = null,
        ?float $overrideAmount = null,
    ): array {
        $principal = round((float) $investment->amount, 2);
        $running = $principal;
        $balances = [];

        $txns = InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->orderBy('id')
            ->get();

        foreach ($txns as $txn) {
            $amount = ($overrideTransactionId !== null && (int) $txn->id === $overrideTransactionId)
                ? round((float) $overrideAmount, 2)
                : round((float) $txn->amount, 2);

            $running = match ($txn->type) {
                InvestmentTransactionType::Deposit => $principal,
                InvestmentTransactionType::Withdrawal => round($running - $amount, 2),
                InvestmentTransactionType::DailyGain,
                InvestmentTransactionType::PeriodAccrual,
                InvestmentTransactionType::MonthlyGain => round($running + $amount, 2),
                default => $running,
            };

            $balances[(int) $txn->id] = $running;
        }

        return [
            'balances' => $balances,
            'final_capital' => $running,
        ];
    }

    /**
     * Vista previa del recálculo de una acreditación por período (modo debug).
     *
     * @return array<string, mixed>
     */
    public static function previewPeriodAccrualRecalculation(InvestmentTransaction $transaction): array
    {
        if ($transaction->type !== InvestmentTransactionType::PeriodAccrual) {
            throw ValidationException::withMessages([
                'transaction' => ['Solo se pueden recalcular acreditaciones por período.'],
            ]);
        }

        if ($transaction->accrual_on === null) {
            throw ValidationException::withMessages([
                'transaction' => ['La transacción no tiene fecha de acreditación.'],
            ]);
        }

        $investment = $transaction->investment;
        if ($investment === null) {
            throw ValidationException::withMessages([
                'transaction' => ['Inversión no encontrada.'],
            ]);
        }

        $correct = self::correctPeriodAccrualForRunDay(
            $investment,
            $transaction->accrual_on->copy()->startOfDay(),
            (int) $transaction->id,
        );

        if ($correct === null) {
            return [
                'needs_change' => false,
                'reason' => 'No se pudo determinar un período válido para esta fecha de ejecución.',
            ];
        }

        $userId = (int) $investment->user_id;
        $currentGain = round((float) $transaction->amount, 2);
        $proposedGain = round($correct['gain'], 2);

        $currentReplay = self::replayInvestmentTransactionBalances($investment);
        $proposedReplay = self::replayInvestmentTransactionBalances(
            $investment,
            (int) $transaction->id,
            $proposedGain,
        );

        $userBalanceBefore = self::currentUserLedgerRunningBalance($userId);
        $userBalanceAfter = round($userBalanceBefore + ($proposedGain - $currentGain), 2);

        $currentSnapshot = self::periodAccrualRecalculationRowSnapshot(
            $transaction,
            $investment,
            $currentGain,
            $transaction->gain_period_start?->copy()->startOfDay(),
            $transaction->gain_period_end?->copy()->startOfDay(),
            (float) ($transaction->applied_percent ?? 0),
            $currentReplay['balances'][(int) $transaction->id] ?? null,
            $currentReplay['final_capital'],
            $userBalanceBefore,
        );

        $proposedSnapshot = self::periodAccrualRecalculationRowSnapshot(
            $transaction,
            $investment,
            $proposedGain,
            $correct['period_start'],
            $correct['period_end_inclusive'],
            (float) ($correct['applied_percent'] ?? 0),
            $proposedReplay['balances'][(int) $transaction->id] ?? null,
            $proposedReplay['final_capital'],
            $userBalanceAfter,
            $correct['plan_percent'],
        );

        $needsChange = abs($proposedGain - $currentGain) >= 0.01
            || $currentSnapshot['period_start'] !== $proposedSnapshot['period_start']
            || $currentSnapshot['period_end'] !== $proposedSnapshot['period_end']
            || abs((float) $currentSnapshot['plan_percent'] - (float) $proposedSnapshot['plan_percent']) >= 0.0001;

        return [
            'needs_change' => $needsChange,
            'transaction_id' => $transaction->id,
            'investment_id' => $investment->id,
            'txid' => $investment->txid,
            'is_imported' => str_starts_with((string) $investment->txid, ForexCsvImportService::IMPORT_TXID_PREFIX),
            'current' => $currentSnapshot,
            'proposed' => $proposedSnapshot,
            'delta' => [
                'gain_usd' => number_format(round($proposedGain - $currentGain, 2), 2, '.', ''),
                'investment_capital_usd' => number_format(
                    round($proposedReplay['final_capital'] - $currentReplay['final_capital'], 2),
                    2,
                    '.',
                    '',
                ),
                'user_ledger_balance_usd' => number_format(
                    round($userBalanceAfter - $userBalanceBefore, 2),
                    2,
                    '.',
                    '',
                ),
            ],
        ];
    }

    /**
     * Aplica el recálculo de una acreditación por período y reconstruye saldos.
     *
     * @return array{gain_delta: float, investment_capital_delta: float}
     */
    public static function applyPeriodAccrualRecalculation(InvestmentTransaction $transaction): array
    {
        $preview = self::previewPeriodAccrualRecalculation($transaction);

        if (($preview['needs_change'] ?? false) !== true) {
            throw ValidationException::withMessages([
                'transaction' => ['Esta acreditación ya coincide con el cálculo esperado.'],
            ]);
        }

        $investment = $transaction->investment;
        if ($investment === null) {
            throw ValidationException::withMessages([
                'transaction' => ['Inversión no encontrada.'],
            ]);
        }

        $correct = self::correctPeriodAccrualForRunDay(
            $investment,
            $transaction->accrual_on->copy()->startOfDay(),
            (int) $transaction->id,
        );

        if ($correct === null) {
            throw ValidationException::withMessages([
                'transaction' => ['No se pudo recalcular el período.'],
            ]);
        }

        $currentGain = round((float) $transaction->amount, 2);
        $proposedGain = round($correct['gain'], 2);
        $capitalBefore = round((float) ($investment->capital_balance ?? $investment->amount), 2);

        return DB::transaction(function () use (
            $transaction,
            $investment,
            $correct,
            $proposedGain,
            $currentGain,
            $capitalBefore,
        ): array {
            $lockedTxn = InvestmentTransaction::query()
                ->whereKey($transaction->id)
                ->lockForUpdate()
                ->first();

            if ($lockedTxn === null) {
                throw ValidationException::withMessages([
                    'transaction' => ['Transacción no encontrada.'],
                ]);
            }

            $lockedInvestment = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

            if ($lockedInvestment === null) {
                throw ValidationException::withMessages([
                    'transaction' => ['Inversión no encontrada.'],
                ]);
            }

            $lockedTxn->update([
                'amount' => $proposedGain,
                'applied_percent' => $correct['applied_percent'],
                'gain_period_start' => $correct['period_start']->toDateString(),
                'gain_period_end' => $correct['period_end_inclusive']->toDateString(),
            ]);

            self::rebuildInvestmentTransactionBalances($lockedInvestment->fresh());
            self::rebuildInvestmentCapitalBalance($lockedInvestment->fresh());
            self::rebuildAccrualThroughDate($lockedInvestment->fresh());
            UserInvestmentPortfolio::syncContractMaturityExtension($lockedInvestment->fresh());
            self::syncInvestmentContractProgress($lockedInvestment->fresh());

            $capitalAfter = round((float) $lockedInvestment->fresh()->capital_balance, 2);

            return [
                'gain_delta' => round($proposedGain - $currentGain, 2),
                'investment_capital_delta' => round($capitalAfter - $capitalBefore, 2),
            ];
        });
    }

    private static function rebuildInvestmentTransactionBalances(Investment $investment): void
    {
        $replay = self::replayInvestmentTransactionBalances($investment);

        foreach ($replay['balances'] as $id => $balance) {
            InvestmentTransaction::query()
                ->whereKey($id)
                ->update(['balance_after' => $balance]);
        }
    }

    /**
     * @return array<string, mixed>
     */
    private static function periodAccrualRecalculationRowSnapshot(
        InvestmentTransaction $transaction,
        Investment $investment,
        float $gain,
        ?CarbonInterface $periodStart,
        ?CarbonInterface $periodEndInclusive,
        float $appliedPercent,
        ?float $balanceAfter,
        float $investmentCapital,
        float $userLedgerBalance,
        ?float $planPercent = null,
    ): array {
        $periodLabel = null;
        $periodStartStr = $periodStart?->toDateString();
        $periodEndStr = $periodEndInclusive?->toDateString();

        if ($periodStart !== null && $periodEndInclusive !== null) {
            $periodLabel = UserInvestmentPortfolio::formatContractMonthLabel(
                $periodStart,
                $periodEndInclusive->copy()->addDay()->startOfDay(),
            );
        }

        $tempTxn = $transaction->replicate();
        $tempTxn->type = InvestmentTransactionType::PeriodAccrual;
        $tempTxn->applied_percent = $appliedPercent;

        return [
            'gain_usd' => number_format($gain, 2, '.', ''),
            'plan_percent' => number_format(
                $planPercent ?? (float) $investment->monthly_return_snapshot,
                2,
                '.',
                '',
            ),
            'applied_percent_label' => self::appliedPercentLabel($tempTxn),
            'period_start' => $periodStartStr,
            'period_end' => $periodEndStr,
            'period_label' => $periodLabel,
            'balance_after_usd' => $balanceAfter !== null
                ? number_format($balanceAfter, 2, '.', '')
                : null,
            'investment_capital_usd' => number_format($investmentCapital, 2, '.', ''),
            'user_ledger_balance_usd' => number_format($userLedgerBalance, 2, '.', ''),
        ];
    }

    /**
     * Acreditación diaria para una sola inversión (misma lógica que el job global).
     */
    public static function applyDailyAccrualForInvestmentId(int $investmentId, CarbonInterface $on): bool
    {
        $calendarDay = Carbon::parse($on)->startOfDay();

        return (bool) DB::transaction(function () use ($investmentId, $calendarDay): bool {
            $locked = Investment::query()
                ->whereKey($investmentId)
                ->lockForUpdate()
                ->first();

            if ($locked === null || $locked->status !== InvestmentStatus::Active) {
                return false;
            }

            $confirmedAt = $locked->confirmed_at;
            $maturityAt = $locked->maturity_at;

            if ($confirmedAt === null || $maturityAt === null) {
                return false;
            }

            if ($calendarDay->lt($confirmedAt->copy()->startOfDay())) {
                return false;
            }

            if ($calendarDay->greaterThanOrEqualTo($maturityAt->copy()->startOfDay())) {
                return false;
            }

            $hasDeposit = InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::Deposit)
                ->exists();

            if (! $hasDeposit) {
                return false;
            }

            $already = InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::DailyGain)
                ->whereDate('accrual_on', $calendarDay->toDateString())
                ->exists();

            if ($already) {
                return false;
            }

            $principal = (float) $locked->amount;
            $monthlyPct = self::currentMonthlyReturnPercent();

            if ($principal <= 0 || $monthlyPct <= 0) {
                return false;
            }

            $daysInMonth = $calendarDay->daysInMonth;
            $gain = round($principal * ($monthlyPct / 100) / $daysInMonth, 2);

            $gain = self::capProposedGainToMonthlyLimit(
                $locked,
                $principal,
                $monthlyPct,
                $calendarDay->copy()->startOfMonth(),
                $gain,
            );

            if ($gain < 0.01) {
                return false;
            }

            $base = (float) ($locked->capital_balance ?? $locked->amount);
            $newBalance = round($base + $gain, 2);

            $effectiveDailyPercent = $daysInMonth > 0
                ? round($monthlyPct / $daysInMonth, 8)
                : null;

            $dailyGain = InvestmentTransaction::query()->create([
                'investment_id' => $locked->id,
                'type' => InvestmentTransactionType::DailyGain,
                'amount' => $gain,
                'balance_after' => $newBalance,
                'platform_month_accrual_id' => null,
                'investment_month_index' => null,
                'applied_percent' => $effectiveDailyPercent,
                'accrual_on' => $calendarDay->toDateString(),
                'gain_period_start' => null,
                'gain_period_end' => null,
                'withdrawal_request_id' => null,
            ]);

            $existingThrough = $locked->accrual_through_date !== null
                ? Carbon::parse($locked->accrual_through_date)->startOfDay()
                : null;
            $throughDate = ($existingThrough !== null && $existingThrough->greaterThan($calendarDay))
                ? $existingThrough
                : $calendarDay->copy();

            $locked->update([
                'capital_balance' => $newBalance,
                'accrual_through_date' => $throughDate->toDateString(),
            ]);

            self::syncInvestmentContractProgress($locked->fresh());

            return true;
        });
    }

    /**
     * Actualiza cupos mensuales y marca la inversión como finalizada cuando el contrato terminó.
     */
    public static function syncInvestmentContractProgress(Investment $investment): void
    {
        $status = InvestmentStatusHelper::coerce($investment);
        $duration = max(1, (int) $investment->duration_months);

        if ($status === InvestmentStatus::Completed) {
            if ((int) $investment->months_credited !== $duration) {
                $investment->update(['months_credited' => $duration]);
            }

            return;
        }

        if ($status !== InvestmentStatus::Active) {
            return;
        }

        UserInvestmentPortfolio::syncContractMaturityExtension($investment->fresh());
        $investment->refresh();

        $credited = self::creditedContractMonthsFromAccrual($investment);
        $updates = [
            'months_credited' => min($duration, $credited),
        ];

        if (self::hasReceivedFinalContractAccrual($investment) || InvestmentStatusHelper::isContractEnded($investment)) {
            $updates['status'] = InvestmentStatus::Completed;
            $updates['months_credited'] = $duration;
        }

        $investment->update($updates);
    }

    /**
     * Reactiva una inversión finalizada para que vuelva a acumular ganancias y figure como activa.
     *
     * @throws ValidationException
     */
    public static function reactivateInvestment(Investment $investment, int $extensionMonths = 0): void
    {
        if (self::isAdminAdjustmentInvestment($investment)) {
            throw ValidationException::withMessages([
                'investment' => ['No se puede reactivar el contenedor de ajustes administrativos.'],
            ]);
        }

        $extensionMonths = max(0, min(12, $extensionMonths));

        DB::transaction(function () use ($investment, $extensionMonths): void {
            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

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

            if ($locked->status !== InvestmentStatus::Completed) {
                throw ValidationException::withMessages([
                    'investment' => ['Solo se pueden reactivar inversiones finalizadas.'],
                ]);
            }

            if ($locked->confirmed_at === null) {
                throw ValidationException::withMessages([
                    'investment' => ['La inversión no tiene fecha de confirmación.'],
                ]);
            }

            $duration = max(1, (int) $locked->duration_months);
            $newDuration = $duration + $extensionMonths;
            $credited = self::creditedContractMonthsFromAccrual($locked);
            $maturityAt = $locked->maturity_at;
            $reactivatedAt = now();

            if ($extensionMonths > 0) {
                $extensionBase = $maturityAt !== null && $maturityAt->gt($reactivatedAt)
                    ? $maturityAt
                    : $reactivatedAt;

                $maturityAt = $extensionBase->copy()->addMonths($extensionMonths);
            } elseif ($maturityAt === null || ! $reactivatedAt->lt($maturityAt)) {
                $maturityAt = $reactivatedAt->copy()->addDay();
            }

            $locked->update([
                'status' => InvestmentStatus::Active,
                'duration_months' => $newDuration,
                'maturity_at' => $maturityAt,
                'months_credited' => min($newDuration, $credited),
            ]);
        });
    }

    /**
     * Marca como finalizada una inversión activa que ya recibió todas sus acreditaciones de contrato.
     *
     * @throws ValidationException
     */
    public static function manualFinalizeFullyAccreditedInvestment(Investment $investment): void
    {
        if (self::isAdminAdjustmentInvestment($investment)) {
            throw ValidationException::withMessages([
                'investment' => ['No se puede finalizar el contenedor de ajustes administrativos.'],
            ]);
        }

        DB::transaction(function () use ($investment): void {
            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

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

            if ($locked->status !== InvestmentStatus::Active) {
                throw ValidationException::withMessages([
                    'investment' => ['Solo se pueden finalizar inversiones activas.'],
                ]);
            }

            $duration = max(1, (int) $locked->duration_months);
            $credited = self::accreditedContractMonths($locked);

            if ($credited < $duration) {
                throw ValidationException::withMessages([
                    'investment' => [
                        "La inversión aún no tiene las {$duration} acreditaciones del contrato (tiene {$credited}).",
                    ],
                ]);
            }

            $locked->update([
                'status' => InvestmentStatus::Completed,
                'months_credited' => $duration,
            ]);
        });
    }

    public static function canManualFinalizeFullyAccreditedInvestment(Investment $investment): bool
    {
        if (self::isAdminAdjustmentInvestment($investment)) {
            return false;
        }

        if ($investment->status !== InvestmentStatus::Active) {
            return false;
        }

        $duration = max(1, (int) $investment->duration_months);

        return self::accreditedContractMonths($investment) >= $duration;
    }

    /**
     * Meses de contrato ya acreditados (tramos completos), para mostrar «N de M acreditaciones».
     */
    public static function accreditedContractMonths(Investment $investment): int
    {
        return UserInvestmentPortfolio::accreditedContractMonths($investment);
    }

    /**
     * Tramos del contrato con al menos una ganancia acreditada (≥ US$ 0,01).
     */
    private static function creditedContractMonthsFromAccrual(Investment $investment): int
    {
        return UserInvestmentPortfolio::accreditedContractMonths($investment);
    }

    /**
     * Indica si ya se acreditó el último tramo del contrato (plan finalizado por acreditaciones).
     */
    private static function hasReceivedFinalContractAccrual(Investment $investment): bool
    {
        if ($investment->accrual_through_date === null) {
            return false;
        }

        $lastAccrualDay = self::lastContractAccrualDay($investment);
        if ($lastAccrualDay === null) {
            return false;
        }

        $through = Carbon::parse($investment->accrual_through_date)->startOfDay();

        return $through->gte($lastAccrualDay);
    }

    private static function lastContractAccrualDay(Investment $investment): ?CarbonInterface
    {
        $intervals = UserInvestmentPortfolio::calendarMonthIntervalsForInvestment($investment);
        if ($intervals !== []) {
            $last = $intervals[array_key_last($intervals)];

            return $last['end']->copy()->subDay()->startOfDay();
        }

        $maturity = $investment->maturity_at?->copy()->startOfDay();
        if ($maturity === null) {
            return null;
        }

        return $maturity->copy()->subDay()->startOfDay();
    }

    /**
     * Revisa inversiones activas y las pasa a finalizadas si ya cumplieron el plazo o vencieron.
     *
     * @return int Inversiones recién marcadas como finalizadas
     */
    public static function finalizeEndedActiveInvestments(): int
    {
        $finalized = 0;

        Investment::query()
            ->where('status', InvestmentStatus::Active)
            ->whereNotNull('confirmed_at')
            ->orderBy('id')
            ->lazyById(100)
            ->each(function (Investment $investment) use (&$finalized): void {
                $wasActive = InvestmentStatusHelper::isActive($investment);
                self::syncInvestmentContractProgress($investment);
                if ($wasActive && InvestmentStatusHelper::isCompleted($investment->fresh())) {
                    $finalized++;
                }
            });

        return $finalized;
    }

    /**
     * Vista previa (sin persistir) de acreditaciones en modo configurado desde la fecha de inicio hasta hoy.
     *
     * @return array{
     *     mode: string,
     *     accrual_day_of_month: int|null,
     *     cutoff_date: string,
     *     lines: list<array{
     *         month_label: string,
     *         accrual_run_date: string|null,
     *         period_start: string,
     *         period_end: string,
     *         gain_usd: float,
     *         balance_after: float,
     *     }>,
     *     total_gain_usd: float,
     *     final_balance_usd: float,
     *     runs_count: int,
     * }
     */
    public static function previewForexImportCatchUpFromStagingItem(
        ForexImportStagingItem $item,
        ?CarbonInterface $cutoff = null,
        ?InvestmentSetting $settings = null,
    ): array {
        $settings ??= InvestmentSetting::current();
        $cutoffCarbon = Carbon::parse($cutoff ?? now())->startOfDay();
        $principal = round((float) $item->amount, 2);
        $monthlyPct = (float) $item->monthly_return_snapshot;
        $frequency = AccrualFrequency::Monthly;

        $empty = static fn (): array => [
            'mode' => $frequency->value,
            'accrual_day_of_month' => $frequency === AccrualFrequency::Monthly ? (int) $settings->accrual_day_of_month : null,
            'cutoff_date' => $cutoffCarbon->toDateString(),
            'lines' => [],
            'total_gain_usd' => 0.0,
            'final_balance_usd' => $principal,
            'runs_count' => 0,
        ];

        if ($principal <= 0 || $monthlyPct <= 0) {
            return $empty();
        }

        $confirmedSoD = $item->confirmed_at?->copy()->startOfDay() ?? $cutoffCarbon->copy();
        $maturityAt = $item->maturity_at?->copy()->startOfDay();
        if ($maturityAt === null) {
            $maturityAt = $confirmedSoD->copy()->addMonths(max(1, (int) $item->duration_months));
        }
        $maturitySoD = $maturityAt->copy()->startOfDay();

        if ($confirmedSoD->gt($cutoffCarbon)) {
            return $empty();
        }

        $lastAccrualDay = $cutoffCarbon->copy();
        if ($lastAccrualDay->greaterThanOrEqualTo($maturitySoD)) {
            $lastAccrualDay = $maturitySoD->copy()->subDay();
        }

        if ($lastAccrualDay->lt($confirmedSoD)) {
            return $empty();
        }

        $lines = [];
        $totalGain = 0.0;
        $balance = $principal;

        if ($frequency === AccrualFrequency::Monthly) {
            $accrualDay = (int) $settings->accrual_day_of_month;
            $dueDays = self::monthlyAccrualRunDaysBetween($confirmedSoD, $lastAccrualDay, $accrualDay);
            $through = null;
            $previewInvestment = new Investment([
                'confirmed_at' => $confirmedSoD,
                'maturity_at' => $maturitySoD,
            ]);
            $simulatedMonthlyCredited = [];

            foreach ($dueDays as $runDay) {
                $window = self::resolveMonthlyAccrualCalendarPeriod(
                    $previewInvestment,
                    $runDay,
                    $through,
                );

                if ($window === null) {
                    continue;
                }

                $periodStart = $window['period_start'];
                $periodEndInclusive = $window['period_end_inclusive'];
                $calendarMonthStart = $window['calendar_month_start'];
                $monthKey = $calendarMonthStart->format('Y-m');

                $gain = self::sumPrincipalBasedDailyAccrualOverInclusiveRange(
                    $principal,
                    $monthlyPct,
                    $periodStart,
                    $periodEndInclusive,
                    $confirmedSoD,
                    $maturitySoD,
                );
                $gain = self::capProposedGainToMonthlyLimit(
                    $previewInvestment,
                    $principal,
                    $monthlyPct,
                    $calendarMonthStart,
                    $gain,
                    $simulatedMonthlyCredited[$monthKey] ?? 0.0,
                );
                if ($gain < 0.01) {
                    continue;
                }

                $simulatedMonthlyCredited[$monthKey] = round(
                    ($simulatedMonthlyCredited[$monthKey] ?? 0.0) + $gain,
                    2,
                );
                $balance = round($balance + $gain, 2);
                $through = $periodEndInclusive;
                $totalGain = round($totalGain + $gain, 2);

                $runLocale = $runDay->copy()->locale('es');
                $lines[] = [
                    'month_label' => $runLocale->isoFormat('MMMM YYYY'),
                    'accrual_run_date' => $runDay->toDateString(),
                    'period_start' => $periodStart->toDateString(),
                    'period_end' => $periodEndInclusive->toDateString(),
                    'gain_usd' => $gain,
                    'balance_after' => $balance,
                ];
            }

            return [
                'mode' => AccrualFrequency::Monthly->value,
                'accrual_day_of_month' => $accrualDay,
                'cutoff_date' => $cutoffCarbon->toDateString(),
                'lines' => $lines,
                'total_gain_usd' => round($totalGain, 2),
                'final_balance_usd' => $balance,
                'runs_count' => count($lines),
            ];
        }

        $bucketYm = null;
        $monthGain = 0.0;
        $monthPeriodStart = '';
        $monthPeriodEnd = '';
        $previewInvestment = new Investment([
            'confirmed_at' => $confirmedSoD,
            'maturity_at' => $maturitySoD,
        ]);
        $simulatedMonthlyCredited = [];
        $d = $confirmedSoD->copy();

        while ($d->lessThanOrEqualTo($lastAccrualDay)) {
            if ($d->greaterThanOrEqualTo($maturitySoD)) {
                break;
            }

            $ym = $d->format('Y-m');
            if ($bucketYm !== null && $ym !== $bucketYm && $monthGain >= 0.01) {
                $monthStartCarbon = Carbon::parse($monthPeriodStart)->locale('es');
                $lines[] = [
                    'month_label' => $monthStartCarbon->isoFormat('MMMM YYYY'),
                    'accrual_run_date' => null,
                    'period_start' => $monthPeriodStart,
                    'period_end' => $monthPeriodEnd,
                    'gain_usd' => round($monthGain, 2),
                    'balance_after' => $balance,
                ];
                $totalGain = round($totalGain + round($monthGain, 2), 2);
                $monthGain = 0.0;
            }
            $bucketYm = $ym;
            if ($monthGain === 0.0) {
                $monthPeriodStart = $d->toDateString();
            }

            $dim = max(1, $d->daysInMonth);
            $proposed = round($principal * ($monthlyPct / 100) / $dim, 2);
            $gain = self::capProposedGainToMonthlyLimit(
                $previewInvestment,
                $principal,
                $monthlyPct,
                $d->copy()->startOfMonth(),
                $proposed,
                $simulatedMonthlyCredited[$ym] ?? 0.0,
            );
            if ($gain >= 0.01) {
                $monthGain += $gain;
                $simulatedMonthlyCredited[$ym] = round(($simulatedMonthlyCredited[$ym] ?? 0.0) + $gain, 2);
                $balance = round($balance + $gain, 2);
            }
            $monthPeriodEnd = $d->toDateString();
            $d = $d->addDay();
        }

        if ($bucketYm !== null && $monthGain >= 0.01) {
            $monthStartCarbon = Carbon::parse($monthPeriodStart)->locale('es');
            $lines[] = [
                'month_label' => $monthStartCarbon->isoFormat('MMMM YYYY'),
                'accrual_run_date' => null,
                'period_start' => $monthPeriodStart,
                'period_end' => $monthPeriodEnd,
                'gain_usd' => round($monthGain, 2),
                'balance_after' => $balance,
            ];
            $totalGain = round($totalGain + round($monthGain, 2), 2);
        }

        return [
            'mode' => AccrualFrequency::Daily->value,
            'accrual_day_of_month' => null,
            'cutoff_date' => $cutoffCarbon->toDateString(),
            'lines' => $lines,
            'total_gain_usd' => round($totalGain, 2),
            'final_balance_usd' => $balance,
            'runs_count' => count($lines),
        ];
    }

    /**
     * Tras importar FOREX: reproduce acreditaciones hasta la fecha indicada (hoy por defecto), según la frecuencia global.
     *
     * @return int Número de ejecuciones que registraron movimiento (días o corridas mensuales con transacción)
     */
    public static function catchUpForexImportAccruals(Investment $investment, ?CarbonInterface $upTo = null): int
    {
        $upToDay = Carbon::parse($upTo ?? now())->startOfDay();
        $settings = InvestmentSetting::current();
        $frequency = AccrualFrequency::Monthly;

        $confirmed = $investment->confirmed_at?->copy()->startOfDay();
        $maturity = $investment->maturity_at?->copy()->startOfDay();
        if ($confirmed === null || $maturity === null) {
            return 0;
        }

        $lastDay = $upToDay->copy();
        if ($lastDay->greaterThanOrEqualTo($maturity)) {
            $lastDay = $maturity->copy()->subDay();
        }

        if ($lastDay->lt($confirmed)) {
            return 0;
        }

        $id = $investment->id;
        $n = 0;

        if ($frequency === AccrualFrequency::Monthly) {
            $dueDays = self::monthlyAccrualRunDaysBetween(
                $confirmed,
                $lastDay,
                (int) $settings->accrual_day_of_month,
            );
            foreach ($dueDays as $runDay) {
                if (self::applyMonthlyPeriodAccrualForInvestmentId($id, $runDay)) {
                    $n++;
                }
            }

            return $n;
        }

        $d = $confirmed->copy();
        while ($d->lessThanOrEqualTo($lastDay)) {
            if ($d->greaterThanOrEqualTo($maturity)) {
                break;
            }
            if (self::applyDailyAccrualForInvestmentId($id, $d)) {
                $n++;
            }
            $d = $d->addDay();
        }

        return $n;
    }

    /**
     * Acredita un día de interés sobre el principal pactado (snapshot mensual / días del mes calendario).
     *
     * @return int Inversiones acreditadas en esta ejecución
     */
    public static function applyDailyAccrualForDate(CarbonInterface $on): int
    {
        $calendarDay = Carbon::parse($on)->startOfDay();
        $credited = 0;

        Investment::query()
            ->where('status', InvestmentStatus::Active)
            ->whereNotNull('confirmed_at')
            ->whereNotNull('maturity_at')
            ->orderBy('id')
            ->lazyById(100)
            ->each(function (Investment $investment) use ($calendarDay, &$credited): void {
                if (self::applyDailyAccrualForInvestmentId($investment->id, $calendarDay)) {
                    $credited++;
                }
            });

        return $credited;
    }

    /**
     * Acreditación agrupada: suma los mismos valores diarios pactados desde el día posterior al último cierre hasta el día de cutoff (fecha de ejecución, recortado antes del vencimiento).
     *
     * @return int inversiones actualizadas
     */
    public static function applyMonthlyPeriodAccrualForDate(CarbonInterface $on): int
    {
        $runDay = Carbon::parse($on)->startOfDay();
        $credited = 0;

        Investment::query()
            ->where('status', InvestmentStatus::Active)
            ->whereNotNull('confirmed_at')
            ->whereNotNull('maturity_at')
            ->orderBy('id')
            ->lazyById(100)
            ->each(function (Investment $investment) use ($runDay, &$credited): void {
                if (self::applyMonthlyPeriodAccrualForInvestmentId($investment->id, $runDay)) {
                    $credited++;
                }
            });

        return $credited;
    }

    /**
     * Llama tras activar una inversión: depósito inicial y saldo igual al capital pactado.
     */
    public static function recordInitialDepositIfMissing(Investment $investment): void
    {
        if ($investment->status !== InvestmentStatus::Active) {
            return;
        }

        DB::transaction(function () use ($investment): void {
            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

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

            $exists = InvestmentTransaction::query()
                ->where('investment_id', $locked->id)
                ->where('type', InvestmentTransactionType::Deposit)
                ->exists();

            if ($exists) {
                return;
            }

            $principal = (float) $locked->amount;

            InvestmentTransaction::query()->create([
                'investment_id' => $locked->id,
                'type' => InvestmentTransactionType::Deposit,
                'amount' => $principal,
                'balance_after' => $principal,
                'platform_month_accrual_id' => null,
                'investment_month_index' => null,
                'applied_percent' => null,
                'accrual_on' => null,
                'gain_period_start' => null,
                'gain_period_end' => null,
                'withdrawal_request_id' => null,
            ]);

            $locked->update([
                'capital_balance' => $principal,
                'months_credited' => $locked->months_credited ?: 0,
                'accrual_through_date' => null,
            ]);
        });
    }

    /**
     * Comisiones de referido al aprobar/activar una inversión (sobre el capital pactado).
     */
    public static function creditReferralBonusesOnInvestmentApproval(Investment $investment): int
    {
        return self::applyInvestmentReferralBonuses($investment, [1, 2]);
    }

    /**
     * Referidos pendientes de acreditar para una inversión (p. ej. inversiones históricas).
     *
     * @return list<array{
     *     level: int,
     *     level_label: string,
     *     beneficiary_user_id: int,
     *     beneficiary_name: string,
     *     beneficiary_email: string|null,
     *     percent: float,
     *     target_amount_usd: float,
     *     already_paid_usd: float,
     *     pending_amount_usd: float,
     * }>
     */
    public static function pendingInvestmentReferralBonuses(Investment $investment): array
    {
        if (! InvestmentStatusHelper::isActiveOrCompleted($investment)) {
            return [];
        }

        if (self::isAdminAdjustmentInvestment($investment)) {
            return [];
        }

        $deposit = self::depositTransactionForInvestment($investment);
        if ($deposit === null) {
            return [];
        }

        /** @var User|null $investor */
        $investor = $investment->user()
            ->with('referrer.referrer')
            ->first();

        if ($investor === null) {
            return [];
        }

        $settings = InvestmentSetting::current();
        $opportunities = [];

        $candidates = [
            1 => [
                'earner' => $investor->referrer,
                'percent' => (float) $settings->referral_direct_percent,
                'limit' => $settings->referral_direct_earning_limit !== null
                    ? (float) $settings->referral_direct_earning_limit
                    : null,
                'label' => 'Directo (nivel 1)',
            ],
            2 => [
                'earner' => $investor->referrer?->referrer,
                'percent' => (float) $settings->referral_indirect_percent,
                'limit' => $settings->referral_indirect_earning_limit !== null
                    ? (float) $settings->referral_indirect_earning_limit
                    : null,
                'label' => 'Indirecto (nivel 2)',
            ],
        ];

        foreach ($candidates as $level => $candidate) {
            /** @var User|null $earner */
            $earner = $candidate['earner'];
            if ($earner === null || $candidate['percent'] <= 0 || ! $earner->referral_program_enabled) {
                continue;
            }

            $target = round((float) $investment->amount * ($candidate['percent'] / 100), 2);
            $alreadyPaid = self::referralPaidOnInvestment($investment->id, $earner->id, $level);
            $pending = self::pendingReferralBonusForInvestment(
                $investment,
                $earner,
                $level,
                $candidate['percent'],
                $candidate['limit'],
            );

            if ($pending < 0.01) {
                continue;
            }

            $opportunities[] = [
                'level' => $level,
                'level_label' => $candidate['label'],
                'beneficiary_user_id' => $earner->id,
                'beneficiary_name' => trim("{$earner->first_name} {$earner->last_name}"),
                'beneficiary_email' => $earner->email,
                'percent' => $candidate['percent'],
                'target_amount_usd' => $target,
                'already_paid_usd' => $alreadyPaid,
                'pending_amount_usd' => $pending,
            ];
        }

        return $opportunities;
    }

    /**
     * Acredita comisiones de referido pendientes para los niveles indicados.
     *
     * @param  list<int>  $levels
     */
    public static function applyInvestmentReferralBonuses(Investment $investment, array $levels): int
    {
        if (! InvestmentStatusHelper::isActiveOrCompleted($investment)) {
            return 0;
        }

        if (self::isAdminAdjustmentInvestment($investment)) {
            return 0;
        }

        $deposit = self::depositTransactionForInvestment($investment);
        if ($deposit === null) {
            return 0;
        }

        $pendingByLevel = collect(self::pendingInvestmentReferralBonuses($investment))
            ->keyBy('level');

        $credited = 0;

        DB::transaction(function () use ($investment, $deposit, $levels, $pendingByLevel, &$credited): void {
            foreach ($levels as $level) {
                $level = (int) $level;
                if (! in_array($level, [1, 2], true)) {
                    continue;
                }

                $pending = $pendingByLevel->get($level);
                if ($pending === null) {
                    continue;
                }

                /** @var User|null $earner */
                $earner = User::query()->find($pending['beneficiary_user_id']);
                if ($earner === null) {
                    continue;
                }

                if (self::createReferralBonusIfEligible(
                    sourceInvestment: $investment,
                    sourceTransaction: $deposit,
                    earner: $earner,
                    level: $level,
                    percent: (float) $pending['percent'],
                    amount: (float) $pending['pending_amount_usd'],
                )) {
                    $credited++;
                }
            }
        });

        return $credited;
    }

    private static function depositTransactionForInvestment(Investment $investment): ?InvestmentTransaction
    {
        return InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->where('type', InvestmentTransactionType::Deposit)
            ->orderBy('id')
            ->first();
    }

    private static function referralPaidOnInvestment(int $investmentId, int $earnerId, int $level): float
    {
        return round((float) InvestmentTransaction::query()
            ->where('investment_id', $investmentId)
            ->where('beneficiary_user_id', $earnerId)
            ->where('referral_level', $level)
            ->whereIn('type', [
                InvestmentTransactionType::ReferralDirectBonus->value,
                InvestmentTransactionType::ReferralIndirectBonus->value,
            ])
            ->sum('amount'), 2);
    }

    private static function pendingReferralBonusForInvestment(
        Investment $investment,
        User $earner,
        int $level,
        float $percent,
        ?float $earningLimit,
    ): float {
        $target = round((float) $investment->amount * ($percent / 100), 2);
        $alreadyPaid = self::referralPaidOnInvestment($investment->id, $earner->id, $level);
        $remaining = round(max(0, $target - $alreadyPaid), 2);

        return self::capReferralBonusAmount($earner->id, $level, $remaining, $earningLimit);
    }

    private static function capReferralBonusAmount(
        int $earnerId,
        int $level,
        float $proposedAmount,
        ?float $limit,
    ): float {
        if ($limit === null) {
            return $proposedAmount;
        }

        $limit = round($limit, 2);
        if ($limit <= 0) {
            return 0;
        }

        $earned = self::referralProgressEarningsAtLevel($earnerId, $level);

        if ($earned >= $limit) {
            return 0;
        }

        return min($proposedAmount, round($limit - $earned, 2));
    }

    private static function createReferralBonusIfEligible(
        Investment $sourceInvestment,
        InvestmentTransaction $sourceTransaction,
        ?User $earner,
        int $level,
        float $percent,
        float $amount,
    ): bool {
        if ($earner === null || $percent <= 0 || ! $earner->referral_program_enabled) {
            return false;
        }

        $amount = round($amount, 2);
        if ($amount < 0.01) {
            return false;
        }

        $type = $level === 1
            ? InvestmentTransactionType::ReferralDirectBonus
            : InvestmentTransactionType::ReferralIndirectBonus;

        $ledgerBalanceBefore = self::currentUserLedgerRunningBalance($earner->id);

        InvestmentTransaction::query()->create([
            'investment_id' => $sourceInvestment->id,
            'beneficiary_user_id' => $earner->id,
            'type' => $type,
            'amount' => $amount,
            'balance_after' => round($ledgerBalanceBefore + $amount, 2),
            'platform_month_accrual_id' => $sourceTransaction->platform_month_accrual_id,
            'investment_month_index' => $sourceTransaction->investment_month_index,
            'applied_percent' => $percent,
            'accrual_on' => null,
            'withdrawal_request_id' => null,
            'referral_level' => $level,
            'referral_source_transaction_id' => $sourceTransaction->id,
        ]);

        self::creditReferralBonusToWithdrawablePool($earner, $amount);

        return true;
    }

    /**
     * Acredita una comisión de referido al contenedor retirable del usuario (disponible de inmediato).
     */
    public static function creditReferralBonusToWithdrawablePool(User $earner, float $amount): void
    {
        $amount = round($amount, 2);
        if ($amount < 0.01) {
            return;
        }

        $investment = self::ensureAdminCreditInvestment($earner);

        $locked = Investment::query()
            ->whereKey($investment->id)
            ->lockForUpdate()
            ->first();

        if ($locked === null || $locked->status !== InvestmentStatus::Active) {
            return;
        }

        $base = (float) ($locked->capital_balance ?? 0);

        $locked->update([
            'capital_balance' => round($base + $amount, 2),
        ]);
    }

    /**
     * Revierte en el contenedor retirable una comisión de referido anulada.
     */
    public static function debitReferralBonusFromWithdrawablePool(int $earnerUserId, float $amount): void
    {
        $amount = round($amount, 2);
        if ($amount < 0.01) {
            return;
        }

        /** @var User|null $earner */
        $earner = User::query()->find($earnerUserId);
        if ($earner === null) {
            return;
        }

        $investment = self::ensureAdminCreditInvestment($earner);

        $locked = Investment::query()
            ->whereKey($investment->id)
            ->lockForUpdate()
            ->first();

        if ($locked === null || $locked->status !== InvestmentStatus::Active) {
            return;
        }

        $base = round((float) ($locked->capital_balance ?? 0), 2);
        $newBalance = round(max(0, $base - $amount), 2);

        $locked->update([
            'capital_balance' => $newBalance,
        ]);
    }

    /**
     * Migra saldo de referidos pendiente al contenedor retirable (usuarios con bonos previos al cambio).
     */
    public static function syncLegacyReferralBalanceToWithdrawablePool(User $user): void
    {
        self::syncUnpooledReferralEarnings($user);
    }

    /**
     * Retiro (reduce capital). El modelo debe estar bloqueado con lockForUpdate en la tx exterior.
     *
     * @throws ValidationException
     */
    public static function applyWithdrawalOnLocked(Investment $locked, float $amount, ?int $withdrawalRequestId = null): void
    {
        if ($amount <= 0) {
            throw ValidationException::withMessages([
                'amount' => ['El monto del retiro debe ser mayor a cero.'],
            ]);
        }

        if (! InvestmentStatusHelper::isActiveOrCompleted($locked)) {
            throw ValidationException::withMessages([
                'investment' => ['Solo se pueden registrar retiros en inversiones activas o finalizadas.'],
            ]);
        }

        $allowable = self::withdrawableFromInvestment($locked);

        if ($amount > $allowable + 0.0001) {
            throw ValidationException::withMessages([
                'amount' => ['El retiro supera el capital disponible según el contrato (principal bloqueado hasta el vencimiento).'],
            ]);
        }

        $base = (float) ($locked->capital_balance ?? $locked->amount);

        if ($amount > $base + 0.0001) {
            throw ValidationException::withMessages([
                'amount' => ['El retiro supera el capital disponible.'],
            ]);
        }

        $newBalance = round($base - $amount, 2);

        InvestmentTransaction::query()->create([
            'investment_id' => $locked->id,
            'type' => InvestmentTransactionType::Withdrawal,
            'amount' => $amount,
            'balance_after' => $newBalance,
            'platform_month_accrual_id' => null,
            'investment_month_index' => null,
            'applied_percent' => null,
            'accrual_on' => null,
            'withdrawal_request_id' => $withdrawalRequestId,
        ]);

        $locked->update([
            'capital_balance' => max(0, $newBalance),
        ]);
    }

    /**
     * Retiro hacia el inversor (usa transacción propia).
     *
     * @throws ValidationException
     */
    public static function recordWithdrawal(Investment $investment, float $amount): void
    {
        DB::transaction(function () use ($investment, $amount): void {
            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

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

            self::applyWithdrawalOnLocked($locked, $amount, null);
        });
    }

    /**
     * Vista previa de consolidación: mueve saldo retirable disperso al contenedor de balance del usuario.
     *
     * @return array{
     *     has_work: bool,
     *     total_withdrawable_usd: float,
     *     container_balance_before_usd: float,
     *     container_balance_after_usd: float,
     *     actions: list<array{
     *         investment_id: int,
     *         txid: string,
     *         amount_usd: float,
     *         description: string
     *     }>
     * }
     */
    public static function previewUserBalanceConsolidation(int $userId): array
    {
        $containerTxid = self::ADMIN_CREDIT_TXID_PREFIX.$userId;
        $container = Investment::query()->where('txid', $containerTxid)->first();
        $containerBefore = round((float) ($container?->capital_balance ?? 0), 2);

        $actions = [];
        $toMove = 0.0;

        Investment::query()
            ->where('user_id', $userId)
            ->whereIn('status', [InvestmentStatus::Active, InvestmentStatus::Completed])
            ->when(
                $container !== null,
                fn ($query) => $query->whereKeyNot($container->id),
            )
            ->orderBy('id')
            ->each(function (Investment $investment) use (&$actions, &$toMove): void {
                $withdrawable = self::withdrawableFromInvestment($investment);
                if ($withdrawable < 0.01) {
                    return;
                }

                $toMove = round($toMove + $withdrawable, 2);
                $actions[] = [
                    'investment_id' => $investment->id,
                    'txid' => (string) $investment->txid,
                    'amount_usd' => $withdrawable,
                    'description' => sprintf(
                        'Mover US$ %s retirables desde la inversión %s al balance general del usuario.',
                        number_format($withdrawable, 2, '.', ','),
                        (string) $investment->txid,
                    ),
                ];
            });

        return [
            'has_work' => $toMove >= 0.01,
            'total_withdrawable_usd' => self::totalWithdrawableForUser($userId),
            'container_balance_before_usd' => $containerBefore,
            'container_balance_after_usd' => round($containerBefore + $toMove, 2),
            'actions' => $actions,
        ];
    }

    /**
     * Consolida saldo retirable disperso en el contenedor de balance del usuario.
     *
     * @return array{
     *     moved_total_usd: float,
     *     container_balance_after_usd: float,
     *     actions: list<array{
     *         investment_id: int,
     *         txid: string,
     *         amount_usd: float,
     *         description: string
     *     }>
     * }
     */
    public static function consolidateUserWithdrawableBalance(User $user): array
    {
        return DB::transaction(function () use ($user): array {
            self::ensureAdminCreditInvestment($user);

            return self::consolidateUserWithdrawableBalanceInternal($user->id);
        });
    }

    /**
     * @return array{
     *     moved_total_usd: float,
     *     container_balance_after_usd: float,
     *     actions: list<array{
     *         investment_id: int,
     *         txid: string,
     *         amount_usd: float,
     *         description: string
     *     }>
     * }
     */
    private static function consolidateUserWithdrawableBalanceInternal(int $userId): array
    {
        /** @var User $user */
        $user = User::query()->findOrFail($userId);
        $container = self::ensureAdminCreditInvestment($user);

        $containerLocked = Investment::query()
            ->whereKey($container->id)
            ->lockForUpdate()
            ->firstOrFail();

        $containerBalance = round((float) ($containerLocked->capital_balance ?? 0), 2);
        $actions = [];
        $movedTotal = 0.0;

        $sourceIds = Investment::query()
            ->where('user_id', $userId)
            ->whereIn('status', [InvestmentStatus::Active, InvestmentStatus::Completed])
            ->whereKeyNot($container->id)
            ->orderBy('id')
            ->pluck('id');

        foreach ($sourceIds as $sourceId) {
            $locked = Investment::query()->whereKey($sourceId)->lockForUpdate()->first();
            if ($locked === null) {
                continue;
            }

            $withdrawable = self::withdrawableFromInvestment($locked);
            if ($withdrawable < 0.01) {
                continue;
            }

            $base = round((float) ($locked->capital_balance ?? $locked->amount), 2);
            $newBase = round(max(0, $base - $withdrawable), 2);

            $locked->update([
                'capital_balance' => $newBase,
            ]);

            $containerBalance = round($containerBalance + $withdrawable, 2);
            $movedTotal = round($movedTotal + $withdrawable, 2);

            $actions[] = [
                'investment_id' => $locked->id,
                'txid' => (string) $locked->txid,
                'amount_usd' => $withdrawable,
                'description' => sprintf(
                    'Se movieron US$ %s retirables desde la inversión %s al balance general del usuario.',
                    number_format($withdrawable, 2, '.', ','),
                    (string) $locked->txid,
                ),
            ];
        }

        if ($movedTotal >= 0.01) {
            $containerLocked->update([
                'capital_balance' => $containerBalance,
            ]);
        }

        return [
            'moved_total_usd' => $movedTotal,
            'container_balance_after_usd' => $containerBalance,
            'actions' => $actions,
        ];
    }

    /**
     * Registra un retiro contra el balance general del usuario (contenedor retirable).
     *
     * @throws ValidationException
     */
    public static function applyUserBalanceWithdrawal(int $userId, float $amount, ?int $withdrawalRequestId = null): void
    {
        $amount = round($amount, 2);
        if ($amount <= 0) {
            throw ValidationException::withMessages([
                'amount' => ['El monto del retiro debe ser mayor a cero.'],
            ]);
        }

        DB::transaction(function () use ($userId, $amount, $withdrawalRequestId): void {
            /** @var User $user */
            $user = User::query()->findOrFail($userId);

            self::consolidateUserWithdrawableBalanceInternal($userId);

            $container = self::ensureAdminCreditInvestment($user);
            $locked = Investment::query()
                ->whereKey($container->id)
                ->lockForUpdate()
                ->firstOrFail();

            $available = self::totalWithdrawableForUser($userId);
            if ($available + 0.0001 < $amount) {
                throw ValidationException::withMessages([
                    'amount' => ['El monto supera el saldo retirable disponible del usuario.'],
                ]);
            }

            self::applyWithdrawalOnLocked($locked, $amount, $withdrawalRequestId);
        });
    }

    /**
     * Retiro administrativo directo sobre el balance general del usuario.
     *
     * @throws ValidationException
     */
    public static function recordUserBalanceWithdrawal(User $user, float $amount): void
    {
        self::applyUserBalanceWithdrawal($user->id, $amount, null);
    }

    /**
     * Aplica un retiro aprobado contra el balance general del usuario.
     *
     * @throws ValidationException
     */
    public static function applyPortfolioWithdrawalForUser(int $userId, float $amount, int $withdrawalRequestId): void
    {
        self::applyUserBalanceWithdrawal($userId, $amount, $withdrawalRequestId);
    }

    /**
     * Descuenta saldo retirable del balance general del usuario (p. ej. nueva inversión desde balance).
     *
     * @throws ValidationException
     */
    public static function applyPortfolioWithdrawableDebit(int $userId, float $amount, ?int $withdrawalRequestId = null): void
    {
        self::applyUserBalanceWithdrawal($userId, $amount, $withdrawalRequestId);
    }

    /**
     * Crea una inversión activa debitando el saldo retirable disponible del usuario (no el principal bloqueado).
     *
     * @throws ValidationException
     */
    public static function activateInvestmentFromPortfolioBalance(
        User $user,
        float $amount,
        int $durationMonths,
        InvestmentSetting $settings,
        ?int $confirmedByUserId = null,
    ): Investment {
        $amount = round($amount, 2);

        return DB::transaction(function () use ($user, $amount, $durationMonths, $settings, $confirmedByUserId): Investment {
            self::applyPortfolioWithdrawableDebit($user->id, $amount, null);

            $confirmedAt = now();
            $maturityAt = Carbon::parse($confirmedAt)->addMonths($durationMonths);
            do {
                $txid = 'INTERNAL-'.strtoupper(str_replace('-', '', (string) Str::uuid()));
            } while (Investment::query()->where('txid', $txid)->exists());

            $investment = Investment::query()->create([
                'user_id' => $user->id,
                'amount' => $amount,
                'duration_months' => $durationMonths,
                'monthly_return_snapshot' => $settings->monthly_return,
                'txid' => $txid,
                'status' => InvestmentStatus::Active,
                'confirmed_at' => $confirmedAt,
                'maturity_at' => $maturityAt,
                'confirmed_by' => $confirmedByUserId,
            ]);

            self::recordInitialDepositIfMissing($investment);
            self::creditReferralBonusesOnInvestmentApproval($investment->fresh());

            /** @var Investment $fresh */
            $fresh = $investment->fresh();

            return $fresh;
        });
    }

    /**
     * Inversión contenedor para créditos manuales del administrador (capital retirable de inmediato).
     */
    public static function ensureAdminCreditInvestment(User $user): Investment
    {
        $txid = self::ADMIN_CREDIT_TXID_PREFIX.$user->id;

        $existing = Investment::query()->where('txid', $txid)->first();
        if ($existing !== null) {
            return $existing;
        }

        $settings = InvestmentSetting::current();
        $now = now();

        return Investment::query()->create([
            'user_id' => $user->id,
            'amount' => 0,
            'capital_balance' => 0,
            'duration_months' => 1,
            'monthly_return_snapshot' => $settings->monthly_return,
            'txid' => $txid,
            'status' => InvestmentStatus::Active,
            'confirmed_at' => $now,
            'maturity_at' => $now,
            'confirmed_by' => null,
        ]);
    }

    /**
     * Acredita saldo retirable al usuario (ajuste administrativo).
     *
     * @throws ValidationException
     */
    public static function applyAdminBalanceCredit(
        User $user,
        float $amount,
        ?string $note = null,
        ?int $createdByUserId = null,
    ): InvestmentTransaction {
        $amount = round($amount, 2);

        if ($amount < 0.01) {
            throw ValidationException::withMessages([
                'amount' => ['El monto debe ser al menos US$ 0,01.'],
            ]);
        }

        return DB::transaction(function () use ($user, $amount, $note, $createdByUserId): InvestmentTransaction {
            $investment = self::ensureAdminCreditInvestment($user);

            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

            if ($locked === null || $locked->status !== InvestmentStatus::Active) {
                throw ValidationException::withMessages([
                    'amount' => ['No se pudo acreditar el saldo en este momento.'],
                ]);
            }

            $base = (float) ($locked->capital_balance ?? 0);
            $newBalance = round($base + $amount, 2);

            /** @var InvestmentTransaction $transaction */
            $transaction = InvestmentTransaction::query()->create([
                'investment_id' => $locked->id,
                'type' => InvestmentTransactionType::AdminAdjustment,
                'amount' => $amount,
                'balance_after' => $newBalance,
                'platform_month_accrual_id' => null,
                'investment_month_index' => null,
                'applied_percent' => null,
                'accrual_on' => null,
                'gain_period_start' => null,
                'gain_period_end' => null,
                'withdrawal_request_id' => null,
                'admin_note' => $note !== null && trim($note) !== '' ? trim($note) : null,
                'created_by_user_id' => $createdByUserId,
            ]);

            $locked->update([
                'capital_balance' => $newBalance,
            ]);

            return $transaction;
        });
    }

    /**
     * Suma al progreso de ganancias L1/L2 sin acreditar saldo retirable ni capital.
     *
     * @throws ValidationException
     */
    public static function applyManualReferralProgressAdjustment(
        User $user,
        int $level,
        float $amount,
        ?string $note = null,
        ?int $createdByUserId = null,
    ): InvestmentTransaction {
        if ($level !== 1 && $level !== 2) {
            throw ValidationException::withMessages([
                'level' => ['El nivel de referido debe ser 1 o 2.'],
            ]);
        }

        $amount = round($amount, 2);

        if ($amount < 0.01) {
            throw ValidationException::withMessages([
                'amount' => ['El monto debe ser al menos US$ 0,01.'],
            ]);
        }

        $type = $level === 1
            ? InvestmentTransactionType::ReferralManualDirectAdjustment
            : InvestmentTransactionType::ReferralManualIndirectAdjustment;

        return DB::transaction(function () use ($user, $level, $amount, $note, $createdByUserId, $type): InvestmentTransaction {
            $investment = self::ensureAdminCreditInvestment($user);
            $ledgerBalance = self::currentUserLedgerRunningBalance($user->id);

            /** @var InvestmentTransaction $transaction */
            $transaction = InvestmentTransaction::query()->create([
                'investment_id' => $investment->id,
                'beneficiary_user_id' => $user->id,
                'type' => $type,
                'amount' => $amount,
                'balance_after' => $ledgerBalance,
                'platform_month_accrual_id' => null,
                'investment_month_index' => null,
                'applied_percent' => null,
                'accrual_on' => null,
                'gain_period_start' => null,
                'gain_period_end' => null,
                'withdrawal_request_id' => null,
                'referral_level' => $level,
                'referral_source_transaction_id' => null,
                'admin_note' => $note !== null && trim($note) !== '' ? trim($note) : null,
                'created_by_user_id' => $createdByUserId,
            ]);

            return $transaction;
        });
    }

    /**
     * @return list<array<string, mixed>>
     */
    public static function manualReferralProgressAdjustmentsForUser(int $userId): array
    {
        return InvestmentTransaction::query()
            ->where('beneficiary_user_id', $userId)
            ->whereIn('type', [
                InvestmentTransactionType::ReferralManualDirectAdjustment->value,
                InvestmentTransactionType::ReferralManualIndirectAdjustment->value,
            ])
            ->with('createdBy:id,first_name,last_name,email')
            ->orderByDesc('id')
            ->get()
            ->map(static fn (InvestmentTransaction $transaction): array => [
                'id' => $transaction->id,
                'level' => (int) $transaction->referral_level,
                'amount_usd' => number_format((float) $transaction->amount, 2, '.', ''),
                'admin_note' => $transaction->admin_note,
                'created_at' => $transaction->created_at?->toIso8601String(),
                'created_by' => $transaction->createdBy !== null
                    ? [
                        'id' => $transaction->createdBy->id,
                        'name' => $transaction->createdBy->name,
                        'email' => $transaction->createdBy->email,
                    ]
                    : null,
            ])
            ->values()
            ->all();
    }

    /**
     * @return list<array<string, mixed>>
     */
    public static function adminBalanceCreditsForUser(int $userId): array
    {
        $txid = self::ADMIN_CREDIT_TXID_PREFIX.$userId;

        $investment = Investment::query()->where('txid', $txid)->first();
        if ($investment === null) {
            return [];
        }

        return InvestmentTransaction::query()
            ->where('investment_id', $investment->id)
            ->where('type', InvestmentTransactionType::AdminAdjustment)
            ->with([
                'createdBy:id,first_name,last_name,email',
                'reversedBy:id,first_name,last_name,email',
            ])
            ->orderByDesc('id')
            ->get()
            ->map(static fn (InvestmentTransaction $transaction): array => [
                'id' => $transaction->id,
                'amount_usd' => number_format((float) $transaction->amount, 2, '.', ''),
                'balance_after_usd' => number_format((float) $transaction->balance_after, 2, '.', ''),
                'admin_note' => $transaction->admin_note,
                'created_at' => $transaction->created_at?->toIso8601String(),
                'created_by' => $transaction->createdBy !== null
                    ? [
                        'id' => $transaction->createdBy->id,
                        'name' => $transaction->createdBy->name,
                        'email' => $transaction->createdBy->email,
                    ]
                    : null,
                'reversed_at' => $transaction->reversed_at?->toIso8601String(),
                'reversed_by' => $transaction->reversedBy !== null
                    ? [
                        'id' => $transaction->reversedBy->id,
                        'name' => $transaction->reversedBy->name,
                        'email' => $transaction->reversedBy->email,
                    ]
                    : null,
                'can_revert' => $transaction->reversed_at === null,
            ])
            ->values()
            ->all();
    }

    /**
     * Revierte una acreditación manual de saldo previamente aplicada.
     *
     * @throws ValidationException
     */
    public static function revertAdminBalanceCredit(
        InvestmentTransaction $credit,
        User $revertedBy,
    ): void {
        if ($credit->type !== InvestmentTransactionType::AdminAdjustment) {
            throw ValidationException::withMessages([
                'transaction' => ['Solo se pueden revertir acreditaciones manuales de saldo.'],
            ]);
        }

        if (! $credit->exists) {
            throw ValidationException::withMessages([
                'transaction' => ['Esta acreditación ya fue revertida.'],
            ]);
        }

        $investment = $credit->investment;
        if ($investment === null || ! self::isAdminAdjustmentInvestment($investment)) {
            throw ValidationException::withMessages([
                'transaction' => ['La acreditación no pertenece al contenedor de ajustes administrativos.'],
            ]);
        }

        $amount = round((float) $credit->amount, 2);

        DB::transaction(function () use ($credit, $investment, $amount): void {
            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

            if ($locked === null || $locked->status !== InvestmentStatus::Active) {
                throw ValidationException::withMessages([
                    'transaction' => ['No se pudo revertir la acreditación en este momento.'],
                ]);
            }

            $currentBalance = round((float) ($locked->capital_balance ?? 0), 2);
            if ($currentBalance + 0.0001 < $amount) {
                throw ValidationException::withMessages([
                    'transaction' => [
                        'No hay saldo suficiente en el contenedor de ajustes para revertir esta acreditación. '
                        .'Es posible que el usuario ya haya retirado o usado parte del monto.',
                    ],
                ]);
            }

            $newBalance = round($currentBalance - $amount, 2);

            $credit->delete();

            $locked->update([
                'capital_balance' => $newBalance,
            ]);
        });
    }

    /**
     * Anula una inversión pendiente o activa. Las activas con capital vigente se liquidan en el libro;
     * las financiadas desde balance interno devuelven el principal al saldo retirable.
     *
     * @throws ValidationException
     */
    public static function voidInvestment(Investment $investment): void
    {
        if (self::isAdminAdjustmentInvestment($investment)) {
            throw ValidationException::withMessages([
                'investment' => ['No se puede anular el contenedor de ajustes administrativos.'],
            ]);
        }

        DB::transaction(function () use ($investment): void {
            $locked = Investment::query()
                ->whereKey($investment->id)
                ->lockForUpdate()
                ->first();

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

            if ($locked->status === InvestmentStatus::Cancelled) {
                throw ValidationException::withMessages([
                    'investment' => ['La inversión ya está anulada.'],
                ]);
            }

            if ($locked->status === InvestmentStatus::Pending) {
                $locked->update(['status' => InvestmentStatus::Cancelled]);

                return;
            }

            if ($locked->status !== InvestmentStatus::Active) {
                throw ValidationException::withMessages([
                    'investment' => ['Solo se pueden anular inversiones pendientes o activas.'],
                ]);
            }

            $capital = round((float) ($locked->capital_balance ?? $locked->amount), 2);

            if ($capital >= 0.01) {
                InvestmentTransaction::query()->create([
                    'investment_id' => $locked->id,
                    'type' => InvestmentTransactionType::Withdrawal,
                    'amount' => $capital,
                    'balance_after' => 0,
                    'platform_month_accrual_id' => null,
                    'investment_month_index' => null,
                    'applied_percent' => null,
                    'accrual_on' => null,
                    'withdrawal_request_id' => null,
                ]);

                $locked->update(['capital_balance' => 0]);
            }

            $principal = round((float) $locked->amount, 2);
            $isInternal = str_starts_with((string) $locked->txid, 'INTERNAL-');

            if ($isInternal && $principal >= 0.01) {
                /** @var User|null $investor */
                $investor = $locked->user()->first();
                if ($investor !== null) {
                    self::applyAdminBalanceCredit($investor, $principal);
                }
            }

            $locked->update([
                'status' => InvestmentStatus::Cancelled,
                'capital_balance' => 0,
            ]);
        });
    }
}
