<?php

namespace App\Support;

use App\ValueObjects\Money;
use InvalidArgumentException;

/**
 * Helper para cálculos financieros complejos con precisión BC Math.
 *
 * Proporciona operaciones que no están disponibles directamente en Money:
 * - Potencias con exponentes fraccionarios/negativos
 * - Cálculos de interés compuesto
 * - Fórmula del método francés (amortización)
 */
class MoneyCalculator
{
    private const INTERNAL_SCALE = 12;

    private const OUTPUT_SCALE = 8;

    /**
     * Calcula potencia con exponente decimal usando expansión de Taylor.
     * bcpow() solo soporta exponentes enteros, esta función soporta decimales.
     *
     * @param  string  $base  Base del exponente
     * @param  string  $exponent  Exponente (puede ser decimal o negativo)
     * @return string Resultado como string
     */
    public static function bcpowDecimal(string $base, string $exponent): string
    {
        // Caso especial: exponente cero
        if (bccomp($exponent, '0', self::INTERNAL_SCALE) === 0) {
            return '1';
        }

        // Caso especial: base cero
        if (bccomp($base, '0', self::INTERNAL_SCALE) === 0) {
            return '0';
        }

        // Caso especial: base uno
        if (bccomp($base, '1', self::INTERNAL_SCALE) === 0) {
            return '1';
        }

        // Caso especial: exponente entero positivo
        if (self::isInteger($exponent) && bccomp($exponent, '0', self::INTERNAL_SCALE) > 0) {
            return bcpow($base, (string) (int) $exponent, self::INTERNAL_SCALE);
        }

        // Para exponentes negativos: b^(-n) = 1 / b^n
        $isNegativeExponent = bccomp($exponent, '0', self::INTERNAL_SCALE) < 0;
        $absExponent = $isNegativeExponent
            ? bcmul($exponent, '-1', self::INTERNAL_SCALE)
            : $exponent;

        // Usar logaritmo natural: b^e = exp(e * ln(b))
        $lnBase = self::bcln($base);
        $product = bcmul($absExponent, $lnBase, self::INTERNAL_SCALE);
        $result = self::bcexp($product);

        if ($isNegativeExponent) {
            $result = bcdiv('1', $result, self::INTERNAL_SCALE);
        }

        return $result;
    }

    /**
     * Logaritmo natural usando serie de Taylor.
     * ln(x) para x > 0
     */
    private static function bcln(string $x): string
    {
        if (bccomp($x, '0', self::INTERNAL_SCALE) <= 0) {
            throw new InvalidArgumentException('ln(x) requiere x > 0');
        }

        // Normalizar x cercano a 1 para mejor convergencia
        // ln(x) = ln(x * 10^k) - k * ln(10)
        $k = 0;
        $ln10 = '2.30258509299404568402';

        while (bccomp($x, '10', self::INTERNAL_SCALE) > 0) {
            $x = bcdiv($x, '10', self::INTERNAL_SCALE);
            $k++;
        }

        while (bccomp($x, '0.1', self::INTERNAL_SCALE) < 0) {
            $x = bcmul($x, '10', self::INTERNAL_SCALE);
            $k--;
        }

        // Usar ln(1+y) = y - y²/2 + y³/3 - ... donde y = x - 1
        // Transformar a ln((1+z)/(1-z)) = 2(z + z³/3 + z⁵/5 + ...) donde z = (x-1)/(x+1)
        $xMinus1 = bcsub($x, '1', self::INTERNAL_SCALE);
        $xPlus1 = bcadd($x, '1', self::INTERNAL_SCALE);
        $z = bcdiv($xMinus1, $xPlus1, self::INTERNAL_SCALE);

        $z2 = bcmul($z, $z, self::INTERNAL_SCALE);
        $sum = $z;
        $term = $z;

        for ($n = 3; $n <= 51; $n += 2) {
            $term = bcmul($term, $z2, self::INTERNAL_SCALE);
            $addition = bcdiv($term, (string) $n, self::INTERNAL_SCALE);
            $sum = bcadd($sum, $addition, self::INTERNAL_SCALE);
        }

        $result = bcmul('2', $sum, self::INTERNAL_SCALE);

        // Ajustar por el factor k
        if ($k !== 0) {
            $adjustment = bcmul((string) $k, $ln10, self::INTERNAL_SCALE);
            $result = bcadd($result, $adjustment, self::INTERNAL_SCALE);
        }

        return $result;
    }

    /**
     * Exponencial e^x usando serie de Taylor.
     */
    private static function bcexp(string $x): string
    {
        // e^x = 1 + x + x²/2! + x³/3! + ...
        $sum = '1';
        $term = '1';

        for ($n = 1; $n <= 50; $n++) {
            $term = bcdiv(bcmul($term, $x, self::INTERNAL_SCALE), (string) $n, self::INTERNAL_SCALE);
            $sum = bcadd($sum, $term, self::INTERNAL_SCALE);

            // Convergencia
            if (bccomp(self::bcabs($term), '0.00000000001', self::INTERNAL_SCALE) < 0) {
                break;
            }
        }

        return $sum;
    }

    /**
     * Valor absoluto BC.
     */
    private static function bcabs(string $value): string
    {
        if (bccomp($value, '0', self::INTERNAL_SCALE) < 0) {
            return bcmul($value, '-1', self::INTERNAL_SCALE);
        }

        return $value;
    }

    /**
     * Verifica si un string es un entero.
     */
    private static function isInteger(string $value): bool
    {
        return bccomp($value, bcadd($value, '0', 0), self::INTERNAL_SCALE) === 0;
    }

    /*=========================================================
    | CÁLCULOS DE INTERÉS
    =========================================================*/

    /**
     * Calcula interés simple.
     * Interés = Principal × Tasa
     *
     * @param  Money  $principal  Capital inicial
     * @param  string  $rate  Tasa como decimal (ej: 0.10 para 10%)
     * @return Money Interés calculado
     */
    public static function simpleInterest(Money $principal, string $rate): Money
    {
        return $principal->multiply($rate);
    }

    /**
     * Calcula interés simple desde porcentaje.
     * Interés = Principal × (Porcentaje / 100)
     *
     * @param  Money  $principal  Capital inicial
     * @param  string  $percentRate  Tasa como porcentaje (ej: 10 para 10%)
     * @return Money Interés calculado
     */
    public static function simpleInterestFromPercent(Money $principal, string $percentRate): Money
    {
        return $principal->percentage($percentRate);
    }

    /**
     * Calcula el valor de cuota fija usando el Método Francés.
     *
     * PMT = P × r × (1+r)^n / ((1+r)^n - 1)
     *
     * Donde:
     * - P = Principal (monto del préstamo)
     * - r = Tasa de interés por período
     * - n = Número de períodos
     *
     * @param  Money  $principal  Monto del préstamo
     * @param  string  $periodicRate  Tasa por período como decimal (ej: 0.01 para 1%)
     * @param  int  $periods  Número de cuotas
     * @return Money Valor de cada cuota
     */
    public static function frenchMethodPayment(Money $principal, string $periodicRate, int $periods): Money
    {
        // Si tasa es 0, simplemente dividir
        if (bccomp($periodicRate, '0', self::INTERNAL_SCALE) === 0) {
            return $principal->divide($periods);
        }

        // (1 + r)^n
        $onePlusR = bcadd('1', $periodicRate, self::INTERNAL_SCALE);
        $onePlusRtoN = self::bcpowDecimal($onePlusR, (string) $periods);

        // Numerador: P × r × (1+r)^n
        $numerator = bcmul($principal->raw(), $periodicRate, self::INTERNAL_SCALE);
        $numerator = bcmul($numerator, $onePlusRtoN, self::INTERNAL_SCALE);

        // Denominador: (1+r)^n - 1
        $denominator = bcsub($onePlusRtoN, '1', self::INTERNAL_SCALE);

        // PMT = numerador / denominador
        $payment = bcdiv($numerator, $denominator, self::OUTPUT_SCALE);

        return Money::of($payment, $principal->precision(), $principal->currency());
    }

    /**
     * Calcula el interés de un período en amortización decreciente.
     * Interés = Saldo × Tasa
     *
     * @param  Money  $balance  Saldo pendiente
     * @param  string  $periodicRate  Tasa por período
     * @return Money Interés del período
     */
    public static function periodInterest(Money $balance, string $periodicRate): Money
    {
        return $balance->multiply($periodicRate);
    }

    /**
     * Convierte tasa anual a tasa por período.
     *
     * @param  string  $annualRate  Tasa anual como decimal
     * @param  int  $periodsPerYear  Períodos por año (12=mensual, 52=semanal, etc.)
     * @return string Tasa por período
     */
    public static function annualToPeriodicRate(string $annualRate, int $periodsPerYear): string
    {
        return bcdiv($annualRate, (string) $periodsPerYear, self::OUTPUT_SCALE);
    }

    /**
     * Convierte porcentaje a decimal.
     * Ej: 10% -> 0.10
     *
     * @param  string  $percent  Porcentaje
     * @return string Decimal
     */
    public static function percentToDecimal(string $percent): string
    {
        return bcdiv($percent, '100', self::OUTPUT_SCALE);
    }

    /*=========================================================
    | DISTRIBUCIÓN DE CUOTAS
    =========================================================*/

    /**
     * Genera un schedule de amortización flat rate (interés fijo).
     *
     * @param  Money  $principal  Monto del préstamo
     * @param  string  $totalRate  Tasa total del plazo como porcentaje
     * @param  int  $installments  Número de cuotas
     * @return array{principal: Money, interest: Money, total: Money, payment: Money}
     */
    public static function flatRateSchedule(Money $principal, string $totalRate, int $installments): array
    {
        $totalInterest = $principal->percentage($totalRate);
        $total = $principal->add($totalInterest);
        $payment = $total->divide($installments);

        return [
            'principal' => $principal,
            'interest' => $totalInterest,
            'total' => $total,
            'payment' => $payment,
            'principalPerInstallment' => $principal->divide($installments),
            'interestPerInstallment' => $totalInterest->divide($installments),
        ];
    }

    /**
     * Genera distribución con última cuota ajustada para sumar exactamente.
     *
     * @param  Money  $total  Total a distribuir
     * @param  int  $parts  Número de partes
     * @return Money[] Array de montos donde la suma es exactamente $total
     */
    public static function distributeExact(Money $total, int $parts): array
    {
        return $total->allocate($parts);
    }

    /**
     * Genera schedule de amortización método francés (cuota fija).
     *
     * @param  Money  $principal  Monto del préstamo
     * @param  string  $periodicRate  Tasa por período como decimal
     * @param  int  $periods  Número de períodos
     * @return array Array de cuotas con principal, interés, cuota y saldo
     */
    public static function frenchMethodSchedule(Money $principal, string $periodicRate, int $periods): array
    {
        $schedule = [];
        $balance = $principal;
        $payment = self::frenchMethodPayment($principal, $periodicRate, $periods);

        for ($i = 1; $i <= $periods; $i++) {
            $interest = self::periodInterest($balance, $periodicRate);

            // Última cuota: ajustar para saldar exactamente
            if ($i === $periods) {
                $principalPart = $balance;
                $payment = $balance->add($interest);
            } else {
                $principalPart = $payment->subtract($interest);
            }

            $balance = $balance->subtract($principalPart);

            $schedule[] = [
                'number' => $i,
                'principal' => $principalPart,
                'interest' => $interest,
                'payment' => $payment,
                'balance' => $balance->max(0),
            ];
        }

        return $schedule;
    }

    /*=========================================================
    | UTILIDADES
    =========================================================*/

    /**
     * Suma un array de Money.
     *
     * @param  Money[]  $amounts
     */
    public static function sum(array $amounts): Money
    {
        if (empty($amounts)) {
            return Money::zero();
        }

        $first = $amounts[0];
        $total = Money::zero($first->precision(), $first->currency());

        foreach ($amounts as $amount) {
            $total = $total->add($amount);
        }

        return $total;
    }

    /**
     * Verifica que la suma de un array de Money sea igual a un total esperado.
     *
     * @param  Money[]  $amounts
     */
    public static function verifiesSum(array $amounts, Money $expectedTotal): bool
    {
        return self::sum($amounts)->equals($expectedTotal);
    }
}
