<?php

namespace App\ValueObjects;

use InvalidArgumentException;
use JsonSerializable;
use Stringable;

/**
 * Value Object para representar y operar con valores monetarios.
 *
 * Usa BC Math internamente para precisión financiera exacta.
 * Almacena valores como strings para evitar errores de punto flotante.
 *
 * IMPORTANTE: Internamente usa 8 decimales de precisión, pero al exportar
 * valores (.value(), .toFloat()) redondea a la precisión configurada (default 2).
 */
final class Money implements JsonSerializable, Stringable
{
    private const DEFAULT_PRECISION = 2;

    private const INTERNAL_SCALE = 8;

    private const DEFAULT_CURRENCY = 'COP';

    private string $amount;

    private int $precision;

    private string $currency;

    public function __construct(float|int|string $amount, int $precision = self::DEFAULT_PRECISION, string $currency = self::DEFAULT_CURRENCY)
    {
        $this->precision = $precision;
        $this->currency = $currency;
        $this->amount = $this->normalize($amount);
    }

    /**
     * Crea una instancia de Money desde un valor numérico.
     */
    public static function of(float|int|string $amount, int $precision = self::DEFAULT_PRECISION, string $currency = self::DEFAULT_CURRENCY): self
    {
        return new self($amount, $precision, $currency);
    }

    /**
     * Crea una instancia de Money con valor cero.
     */
    public static function zero(int $precision = self::DEFAULT_PRECISION, string $currency = self::DEFAULT_CURRENCY): self
    {
        return new self(0, $precision, $currency);
    }

    /**
     * Crea Money desde centavos (entero).
     */
    public static function fromCents(int $cents, int $precision = self::DEFAULT_PRECISION, string $currency = self::DEFAULT_CURRENCY): self
    {
        $divisor = bcpow('10', (string) $precision, 0);
        $amount = bcdiv((string) $cents, $divisor, self::INTERNAL_SCALE);

        return new self($amount, $precision, $currency);
    }

    /**
     * Retorna el valor como float redondeado a la precisión configurada.
     */
    public function value(): float
    {
        return (float) bcadd($this->amount, '0', $this->precision);
    }

    /**
     * Retorna el valor como float (alias de value()).
     */
    public function toFloat(): float
    {
        return $this->value();
    }

    /**
     * Retorna el valor como string con la precisión configurada.
     */
    public function toString(): string
    {
        return bcadd($this->amount, '0', $this->precision);
    }

    /**
     * Retorna el valor interno sin redondear (para cálculos intermedios).
     */
    public function raw(): string
    {
        return $this->amount;
    }

    /**
     * Retorna el valor como centavos (entero).
     *
     * Se deriva del valor ya truncado a `precision` decimales (igual que
     * toString()/value()), de modo que cents() y toString() nunca diverjan:
     * sin truncar primero, el monto interno de 8 decimales (p. ej. "0.98500000")
     * daría un número de centavos distinto al del valor mostrado.
     */
    public function cents(): int
    {
        $rounded = bcadd($this->amount, '0', $this->precision);
        $multiplier = bcpow('10', (string) $this->precision, 0);
        $cents = bcmul($rounded, $multiplier, 0);

        return (int) $cents;
    }

    /**
     * Retorna la moneda.
     */
    public function currency(): string
    {
        return $this->currency;
    }

    /**
     * Retorna la precisión.
     */
    public function precision(): int
    {
        return $this->precision;
    }

    /*=========================================================
    | OPERACIONES ARITMÉTICAS (inmutables)
    =========================================================*/

    /**
     * Suma otro Money y retorna una nueva instancia.
     */
    public function add(Money|float|int|string $amount): self
    {
        $value = $this->extractValue($amount);
        $result = bcadd($this->amount, $value, self::INTERNAL_SCALE);

        return $this->newInstance($result);
    }

    /**
     * Resta otro Money y retorna una nueva instancia.
     */
    public function subtract(Money|float|int|string $amount): self
    {
        $value = $this->extractValue($amount);
        $result = bcsub($this->amount, $value, self::INTERNAL_SCALE);

        return $this->newInstance($result);
    }

    /**
     * Multiplica por un factor y retorna una nueva instancia.
     */
    public function multiply(float|int|string $factor): self
    {
        // normalize() evita la notación científica de (string)$float (p. ej.
        // "1.0E-7"), que BCMath interpretaría como "1" → cálculo erróneo.
        $result = bcmul($this->amount, $this->normalize($factor), self::INTERNAL_SCALE);

        return $this->newInstance($result);
    }

    /**
     * Divide por un divisor y retorna una nueva instancia.
     */
    public function divide(float|int|string $divisor): self
    {
        $normalizedDivisor = $this->normalize($divisor);
        if (bccomp($normalizedDivisor, '0', self::INTERNAL_SCALE) === 0) {
            throw new InvalidArgumentException('No se puede dividir por cero.');
        }
        $result = bcdiv($this->amount, $normalizedDivisor, self::INTERNAL_SCALE);

        return $this->newInstance($result);
    }

    /**
     * Calcula el porcentaje de este monto.
     * Ejemplo: $100->percentage(10) = $10
     */
    public function percentage(float|int|string $percent): self
    {
        $factor = bcdiv($this->normalize($percent), '100', self::INTERNAL_SCALE);
        $result = bcmul($this->amount, $factor, self::INTERNAL_SCALE);

        return $this->newInstance($result);
    }

    /**
     * Retorna el valor absoluto.
     */
    public function abs(): self
    {
        if (bccomp($this->amount, '0', self::INTERNAL_SCALE) < 0) {
            return $this->newInstance(bcmul($this->amount, '-1', self::INTERNAL_SCALE));
        }

        return $this->newInstance($this->amount);
    }

    /**
     * Retorna el valor negativo.
     */
    public function negate(): self
    {
        return $this->newInstance(bcmul($this->amount, '-1', self::INTERNAL_SCALE));
    }

    /**
     * Retorna el mínimo entre este monto y otro.
     */
    public function min(Money|float|int|string $amount): self
    {
        $value = $this->extractValue($amount);
        if (bccomp($this->amount, $value, self::INTERNAL_SCALE) <= 0) {
            return $this->newInstance($this->amount);
        }

        return $this->newInstance($value);
    }

    /**
     * Retorna el máximo entre este monto y otro.
     */
    public function max(Money|float|int|string $amount): self
    {
        $value = $this->extractValue($amount);
        if (bccomp($this->amount, $value, self::INTERNAL_SCALE) >= 0) {
            return $this->newInstance($this->amount);
        }

        return $this->newInstance($value);
    }

    /**
     * Distribuye este monto en N partes iguales.
     * Retorna un array de Money donde la suma es exactamente igual al original.
     * La última cuota absorbe cualquier diferencia de redondeo.
     *
     * @return Money[]
     */
    public function allocate(int $parts): array
    {
        if ($parts <= 0) {
            throw new InvalidArgumentException('El número de partes debe ser mayor a cero.');
        }

        $perPart = bcdiv($this->amount, (string) $parts, self::INTERNAL_SCALE);
        $rounded = bcadd($perPart, '0', $this->precision);

        $allocated = [];
        $totalAllocated = '0';

        for ($i = 0; $i < $parts - 1; $i++) {
            $allocated[] = $this->newInstance($rounded);
            $totalAllocated = bcadd($totalAllocated, $rounded, self::INTERNAL_SCALE);
        }

        // Última parte: diferencia para que sume exactamente
        $lastPart = bcsub($this->amount, $totalAllocated, self::INTERNAL_SCALE);
        $allocated[] = $this->newInstance($lastPart);

        return $allocated;
    }

    /**
     * Distribuye según ratios proporcionales.
     * Ejemplo: $100->allocateByRatios([1, 2, 1]) = [$25, $50, $25]
     *
     * @param  array<int|float>  $ratios
     * @return Money[]
     */
    public function allocateByRatios(array $ratios): array
    {
        $total = array_sum($ratios);
        if ($total <= 0) {
            throw new InvalidArgumentException('La suma de ratios debe ser mayor a cero.');
        }

        $allocated = [];
        $totalAllocated = '0';
        $count = count($ratios);

        for ($i = 0; $i < $count - 1; $i++) {
            $share = bcmul($this->amount, (string) ($ratios[$i] / $total), self::INTERNAL_SCALE);
            $rounded = bcadd($share, '0', $this->precision);
            $allocated[] = $this->newInstance($rounded);
            $totalAllocated = bcadd($totalAllocated, $rounded, self::INTERNAL_SCALE);
        }

        // Última parte absorbe la diferencia
        $lastPart = bcsub($this->amount, $totalAllocated, self::INTERNAL_SCALE);
        $allocated[] = $this->newInstance($lastPart);

        return $allocated;
    }

    /*=========================================================
    | COMPARACIONES
    =========================================================*/

    /**
     * Verifica si es igual a otro monto (con la precisión configurada).
     */
    public function equals(Money|float|int|string $amount): bool
    {
        $value = $this->extractValue($amount);

        return bccomp($this->amount, $value, $this->precision) === 0;
    }

    /**
     * Verifica si es mayor que otro monto.
     */
    public function greaterThan(Money|float|int|string $amount): bool
    {
        $value = $this->extractValue($amount);

        return bccomp($this->amount, $value, $this->precision) > 0;
    }

    /**
     * Verifica si es mayor o igual que otro monto.
     */
    public function greaterThanOrEqual(Money|float|int|string $amount): bool
    {
        $value = $this->extractValue($amount);

        return bccomp($this->amount, $value, $this->precision) >= 0;
    }

    /**
     * Verifica si es menor que otro monto.
     */
    public function lessThan(Money|float|int|string $amount): bool
    {
        $value = $this->extractValue($amount);

        return bccomp($this->amount, $value, $this->precision) < 0;
    }

    /**
     * Verifica si es menor o igual que otro monto.
     */
    public function lessThanOrEqual(Money|float|int|string $amount): bool
    {
        $value = $this->extractValue($amount);

        return bccomp($this->amount, $value, $this->precision) <= 0;
    }

    /**
     * Verifica si es cero.
     */
    public function isZero(): bool
    {
        return bccomp($this->amount, '0', $this->precision) === 0;
    }

    /**
     * Verifica si es positivo (> 0).
     */
    public function isPositive(): bool
    {
        return bccomp($this->amount, '0', $this->precision) > 0;
    }

    /**
     * Verifica si es negativo (< 0).
     */
    public function isNegative(): bool
    {
        return bccomp($this->amount, '0', $this->precision) < 0;
    }

    /**
     * Verifica si es cero o negativo (<= 0).
     */
    public function isZeroOrNegative(): bool
    {
        return bccomp($this->amount, '0', $this->precision) <= 0;
    }

    /**
     * Verifica si es cero o positivo (>= 0).
     */
    public function isZeroOrPositive(): bool
    {
        return bccomp($this->amount, '0', $this->precision) >= 0;
    }

    /*=========================================================
    | FORMATO Y CONVERSIÓN
    =========================================================*/

    /**
     * Formatea el monto como string con separadores.
     */
    public function format(string $decimalSeparator = ',', string $thousandsSeparator = '.'): string
    {
        return number_format($this->value(), $this->precision, $decimalSeparator, $thousandsSeparator);
    }

    /**
     * Formatea con símbolo de moneda.
     */
    public function formatWithCurrency(string $symbol = '$'): string
    {
        return $symbol.' '.$this->format();
    }

    /*=========================================================
    | MÉTODOS PRIVADOS
    =========================================================*/

    /**
     * Normaliza el valor a string con precisión interna.
     */
    private function normalize(float|int|string $value): string
    {
        // Convertir a string manteniendo precisión
        if (is_float($value) || is_int($value)) {
            // Usar sprintf para evitar notación científica
            $value = sprintf('%.'.self::INTERNAL_SCALE.'F', $value);
        }

        return bcadd((string) $value, '0', self::INTERNAL_SCALE);
    }

    /**
     * Extrae el valor string de un Money o escalar.
     */
    private function extractValue(Money|float|int|string $amount): string
    {
        if ($amount instanceof Money) {
            return $amount->raw();
        }

        return $this->normalize($amount);
    }

    /**
     * Crea una nueva instancia con el mismo precision y currency.
     */
    private function newInstance(string $amount): self
    {
        $instance = new self(0, $this->precision, $this->currency);
        $instance->amount = $amount;

        return $instance;
    }

    /*=========================================================
    | INTERFACES
    =========================================================*/

    public function jsonSerialize(): mixed
    {
        return [
            'amount' => $this->value(),
            'currency' => $this->currency,
            'precision' => $this->precision,
        ];
    }

    public function __toString(): string
    {
        return $this->toString();
    }

    public function toArray(): array
    {
        return [
            'amount' => $this->value(),
            'currency' => $this->currency,
            'precision' => $this->precision,
            'formatted' => $this->formatWithCurrency(),
        ];
    }
}
