<?php

declare(strict_types=1);

namespace Brick\Money;

use Brick\Money\Context\DefaultContext;
use Brick\Money\Exception\MoneyMismatchException;
use Brick\Money\Exception\UnknownCurrencyException;

use Brick\Math\BigDecimal;
use Brick\Math\BigInteger;
use Brick\Math\BigNumber;
use Brick\Math\BigRational;
use Brick\Math\RoundingMode;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;

/**
 * A monetary value in a given currency. This class is immutable.
 *
 * A Money has an amount, a currency, and a context. The context defines the scale of the amount, and an optional cash
 * rounding step, for monies that do not have coins or notes for their smallest units.
 *
 * All operations on a Money return another Money with the same context. The available contexts are:
 *
 * - DefaultContext handles monies with the default scale for the currency.
 * - CashContext is similar to DefaultContext, but supports a cash rounding step.
 * - CustomContext handles monies with a custom scale, and optionally step.
 * - AutoContext automatically adjusts the scale of the money to the minimum required.
 */
final class Money extends AbstractMoney
{
    /**
     * The amount.
     *
     * @var \Brick\Math\BigDecimal
     */
    private $amount;

    /**
     * The currency.
     *
     * @var \Brick\Money\Currency
     */
    private $currency;

    /**
     * The context that defines the capability of this Money.
     *
     * @var Context
     */
    private $context;

    /**
     * @param BigDecimal $amount
     * @param Currency   $currency
     * @param Context    $context
     */
    private function __construct(BigDecimal $amount, Currency $currency, Context $context)
    {
        $this->amount   = $amount;
        $this->currency = $currency;
        $this->context  = $context;
    }

    /**
     * Returns the minimum of the given monies.
     *
     * If several monies are equal to the minimum value, the first one is returned.
     *
     * @param Money    $money  The first money.
     * @param Money ...$monies The subsequent monies.
     *
     * @return Money
     *
     * @throws MoneyMismatchException If all the monies are not in the same currency.
     */
    public static function min(Money $money, Money ...$monies) : Money
    {
        $min = $money;

        foreach ($monies as $money) {
            if ($money->isLessThan($min)) {
                $min = $money;
            }
        }

        return $min;
    }

    /**
     * Returns the maximum of the given monies.
     *
     * If several monies are equal to the maximum value, the first one is returned.
     *
     * @param Money    $money  The first money.
     * @param Money ...$monies The subsequent monies.
     *
     * @return Money
     *
     * @throws MoneyMismatchException If all the monies are not in the same currency.
     */
    public static function max(Money $money, Money ...$monies) : Money
    {
        $max = $money;

        foreach ($monies as $money) {
            if ($money->isGreaterThan($max)) {
                $max = $money;
            }
        }

        return $max;
    }

    /**
     * Returns the total of the given monies.
     *
     * The monies must share the same currency and context.
     *
     * @param Money    $money  The first money.
     * @param Money ...$monies The subsequent monies.
     *
     * @return Money
     *
     * @throws MoneyMismatchException If all the monies are not in the same currency and context.
     */
    public static function total(Money $money, Money ...$monies) : Money
    {
        $total = $money;

        foreach ($monies as $money) {
            $total = $total->plus($money);
        }

        return $total;
    }

    /**
     * Creates a Money from a rational amount, a currency, and a context.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param BigNumber $amount       The amount.
     * @param Currency  $currency     The currency.
     * @param Context   $context      The context.
     * @param int       $roundingMode An optional rounding mode if the amount does not fit the context.
     *
     * @return Money
     *
     * @throws RoundingNecessaryException If RoundingMode::UNNECESSARY is used but rounding is necessary.
     */
    public static function create(BigNumber $amount, Currency $currency, Context $context, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        $amount = $context->applyTo($amount, $currency, $roundingMode);

        return new Money($amount, $currency, $context);
    }

    /**
     * Returns a Money of the given amount and currency.
     *
     * By default, the money is created with a DefaultContext. This means that the amount is scaled to match the
     * currency's default fraction digits. For example, `Money::of('2.5', 'USD')` will yield `USD 2.50`.
     * If the amount cannot be safely converted to this scale, an exception is thrown.
     *
     * To override this behaviour, a Context instance can be provided.
     * Operations on this Money return a Money with the same context.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param BigNumber|int|float|string $amount       The monetary amount.
     * @param Currency|string|int        $currency     The Currency instance, ISO currency code or ISO numeric currency code.
     * @param Context|null               $context      An optional Context.
     * @param int                        $roundingMode An optional RoundingMode, if the amount does not fit the context.
     *
     * @return Money
     *
     * @throws NumberFormatException      If the amount is a string in a non-supported format.
     * @throws UnknownCurrencyException   If the currency is an unknown currency code.
     * @throws RoundingNecessaryException If the rounding mode is RoundingMode::UNNECESSARY, and rounding is necessary
     *                                    to represent the amount at the requested scale.
     */
    public static function of($amount, $currency, ?Context $context = null, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        if (! $currency instanceof Currency) {
            $currency = Currency::of($currency);
        }

        if ($context === null) {
            $context = new DefaultContext();
        }

        $amount = BigNumber::of($amount);

        return self::create($amount, $currency, $context, $roundingMode);
    }

    /**
     * Returns a Money from a number of minor units.
     *
     * By default, the money is created with a DefaultContext. This means that the amount is scaled to match the
     * currency's default fraction digits. For example, `Money::ofMinor(1234, 'USD')` will yield `USD 12.34`.
     * If the amount cannot be safely converted to this scale, an exception is thrown.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param BigNumber|int|float|string $minorAmount  The amount, in minor currency units.
     * @param Currency|string|int        $currency     The Currency instance, ISO currency code or ISO numeric currency code.
     * @param Context|null               $context      An optional Context.
     * @param int                        $roundingMode An optional RoundingMode, if the amount does not fit the context.
     *
     * @return Money
     *
     * @throws NumberFormatException      If the amount is a string in a non-supported format.
     * @throws UnknownCurrencyException   If the currency is an unknown currency code.
     * @throws RoundingNecessaryException If the rounding mode is RoundingMode::UNNECESSARY, and rounding is necessary
     *                                    to represent the amount at the requested scale.
     */
    public static function ofMinor($minorAmount, $currency, ?Context $context = null, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        if (! $currency instanceof Currency) {
            $currency = Currency::of($currency);
        }

        if ($context === null) {
            $context = new DefaultContext();
        }

        $amount = BigRational::of($minorAmount)->dividedBy(10 ** $currency->getDefaultFractionDigits());

        return self::create($amount, $currency, $context, $roundingMode);
    }

    /**
     * Returns a Money with zero value, in the given currency.
     *
     * By default, the money is created with a DefaultContext: it has the default scale for the currency.
     * A Context instance can be provided to override the default.
     *
     * @param Currency|string|int $currency The Currency instance, ISO currency code or ISO numeric currency code.
     * @param Context|null        $context  An optional context.
     *
     * @return Money
     */
    public static function zero($currency, ?Context $context = null) : Money
    {
        if (! $currency instanceof Currency) {
            $currency = Currency::of($currency);
        }

        if ($context === null) {
            $context = new DefaultContext();
        }

        $amount = BigDecimal::zero();

        return self::create($amount, $currency, $context);
    }

    /**
     * Returns the amount of this Money, as a BigDecimal.
     *
     * @return BigDecimal
     */
    public function getAmount() : BigDecimal
    {
        return $this->amount;
    }

    /**
     * Returns the amount of this Money in minor units (cents) for the currency.
     *
     * The value is returned as a BigDecimal. If this Money has a scale greater than that of the currency, the result
     * will have a non-zero scale.
     *
     * For example, `USD 1.23` will return a BigDecimal of `123`, while `USD 1.2345` will return `123.45`.
     *
     * @return BigDecimal
     */
    public function getMinorAmount() : BigDecimal
    {
        return $this->amount->withPointMovedRight($this->currency->getDefaultFractionDigits());
    }

    /**
     * Returns a BigInteger containing the unscaled value (all digits) of this money.
     *
     * For example, `123.4567 USD` will return a BigInteger of `1234567`.
     *
     * @return BigInteger
     */
    public function getUnscaledAmount() : BigInteger
    {
        return $this->amount->getUnscaledValue();
    }

    /**
     * Returns the Currency of this Money.
     *
     * @return Currency
     */
    public function getCurrency() : Currency
    {
        return $this->currency;
    }

    /**
     * Returns the Context of this Money.
     *
     * @return Context
     */
    public function getContext() : Context
    {
        return $this->context;
    }

    /**
     * Returns the sum of this Money and the given amount.
     *
     * If the operand is a Money, it must have the same context as this Money, or an exception is thrown.
     * This is by design, to ensure that contexts are not mixed accidentally.
     * If you do need to add a Money in a different context, you can use `plus($money->toRational())`.
     *
     * The resulting Money has the same context as this Money. If the result needs rounding to fit this context, a
     * rounding mode can be provided. If a rounding mode is not provided and rounding is necessary, an exception is
     * thrown.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param AbstractMoney|BigNumber|int|float|string $that         The money or amount to add.
     * @param int                                      $roundingMode An optional RoundingMode constant.
     *
     * @return Money
     *
     * @throws MathException          If the argument is an invalid number or rounding is necessary.
     * @throws MoneyMismatchException If the argument is a money in a different currency or in a different context.
     */
    public function plus($that, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        $amount = $this->getAmountOf($that);

        if ($that instanceof Money) {
            $this->checkContext($that->getContext(), __FUNCTION__);

            if ($this->context->isFixedScale()) {
                return new Money($this->amount->plus($that->amount), $this->currency, $this->context);
            }
        }

        $amount = $this->amount->toBigRational()->plus($amount);

        return self::create($amount, $this->currency, $this->context, $roundingMode);
    }

    /**
     * Returns the difference of this Money and the given amount.
     *
     * If the operand is a Money, it must have the same context as this Money, or an exception is thrown.
     * This is by design, to ensure that contexts are not mixed accidentally.
     * If you do need to subtract a Money in a different context, you can use `minus($money->toRational())`.
     *
     * The resulting Money has the same context as this Money. If the result needs rounding to fit this context, a
     * rounding mode can be provided. If a rounding mode is not provided and rounding is necessary, an exception is
     * thrown.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param AbstractMoney|BigNumber|int|float|string $that         The money or amount to subtract.
     * @param int                                      $roundingMode An optional RoundingMode constant.
     *
     * @return Money
     *
     * @throws MathException          If the argument is an invalid number or rounding is necessary.
     * @throws MoneyMismatchException If the argument is a money in a different currency or in a different context.
     */
    public function minus($that, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        $amount = $this->getAmountOf($that);

        if ($that instanceof Money) {
            $this->checkContext($that->getContext(), __FUNCTION__);

            if ($this->context->isFixedScale()) {
                return new Money($this->amount->minus($that->amount), $this->currency, $this->context);
            }
        }

        $amount = $this->amount->toBigRational()->minus($amount);

        return self::create($amount, $this->currency, $this->context, $roundingMode);
    }

    /**
     * Returns the product of this Money and the given number.
     *
     * The resulting Money has the same context as this Money. If the result needs rounding to fit this context, a
     * rounding mode can be provided. If a rounding mode is not provided and rounding is necessary, an exception is
     * thrown.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param BigNumber|int|float|string $that         The multiplier.
     * @param int                        $roundingMode An optional RoundingMode constant.
     *
     * @return Money
     *
     * @throws MathException If the argument is an invalid number or rounding is necessary.
     */
    public function multipliedBy($that, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        $amount = $this->amount->toBigRational()->multipliedBy($that);

        return self::create($amount, $this->currency, $this->context, $roundingMode);
    }

    /**
     * Returns the result of the division of this Money by the given number.
     *
     * The resulting Money has the same context as this Money. If the result needs rounding to fit this context, a
     * rounding mode can be provided. If a rounding mode is not provided and rounding is necessary, an exception is
     * thrown.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param BigNumber|int|float|string $that         The divisor.
     * @param int                        $roundingMode An optional RoundingMode constant.
     *
     * @return Money
     *
     * @throws MathException If the argument is an invalid number or is zero, or rounding is necessary.
     */
    public function dividedBy($that, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        $amount = $this->amount->toBigRational()->dividedBy($that);

        return self::create($amount, $this->currency, $this->context, $roundingMode);
    }

    /**
     * Returns the quotient of the division of this Money by the given number.
     *
     * The given number must be a integer value. The resulting Money has the same context as this Money.
     * This method can serve as a basis for a money allocation algorithm.
     *
     * @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigInteger.
     *
     * @return Money
     *
     * @throws MathException If the divisor cannot be converted to a BigInteger.
     */
    public function quotient($that) : Money
    {
        $that = BigInteger::of($that);
        $step = $this->context->getStep();

        $scale  = $this->amount->getScale();
        $amount = $this->amount->withPointMovedRight($scale)->dividedBy($step);

        $q = $amount->quotient($that);
        $q = $q->multipliedBy($step)->withPointMovedLeft($scale);

        return new Money($q, $this->currency, $this->context);
    }

    /**
     * Returns the quotient and the remainder of the division of this Money by the given number.
     *
     * The given number must be an integer value. The resulting monies have the same context as this Money.
     * This method can serve as a basis for a money allocation algorithm.
     *
     * @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigInteger.
     *
     * @return Money[] The quotient and the remainder.
     *
     * @throws MathException If the divisor cannot be converted to a BigInteger.
     */
    public function quotientAndRemainder($that) : array
    {
        $that = BigInteger::of($that);
        $step = $this->context->getStep();

        $scale  = $this->amount->getScale();
        $amount = $this->amount->withPointMovedRight($scale)->dividedBy($step);

        [$q, $r] = $amount->quotientAndRemainder($that);

        $q = $q->multipliedBy($step)->withPointMovedLeft($scale);
        $r = $r->multipliedBy($step)->withPointMovedLeft($scale);

        $quotient  = new Money($q, $this->currency, $this->context);
        $remainder = new Money($r, $this->currency, $this->context);

        return [$quotient, $remainder];
    }

    /**
     * Allocates this Money according to a list of ratios.
     *
     * If the allocation yields a remainder, its amount is split over the first monies in the list,
     * so that the total of the resulting monies is always equal to this Money.
     *
     * For example, given a `USD 49.99` money in the default context,
     * `allocate(1, 2, 3, 4)` returns [`USD 5.00`, `USD 10.00`, `USD 15.00`, `USD 19.99`]
     *
     * The resulting monies have the same context as this Money.
     *
     * @param int[] $ratios The ratios.
     *
     * @return Money[]
     *
     * @throws \InvalidArgumentException If called with invalid parameters.
     */
    public function allocate(int ...$ratios) : array
    {
        if (! $ratios) {
            throw new \InvalidArgumentException('Cannot allocate() an empty list of ratios.');
        }

        foreach ($ratios as $ratio) {
            if ($ratio < 0) {
                throw new \InvalidArgumentException('Cannot allocate() negative ratios.');
            }
        }

        $total = array_sum($ratios);

        if ($total === 0) {
            throw new \InvalidArgumentException('Cannot allocate() to zero ratios only.');
        }

        $step = $this->context->getStep();

        $monies = [];

        $unit = BigDecimal::ofUnscaledValue($step, $this->amount->getScale());
        $unit = new Money($unit, $this->currency, $this->context);

        if ($this->isNegative()) {
            $unit = $unit->negated();
        }

        $remainder = $this;

        foreach ($ratios as $ratio) {
            $money = $this->multipliedBy($ratio)->quotient($total);
            $remainder = $remainder->minus($money);
            $monies[] = $money;
        }

        foreach ($monies as $key => $money) {
            if ($remainder->isZero()) {
                break;
            }

            $monies[$key] = $money->plus($unit);
            $remainder = $remainder->minus($unit);
        }

        return $monies;
    }

    /**
     * Allocates this Money according to a list of ratios.
     *
     * The remainder is also present, appended at the end of the list.
     *
     * For example, given a `USD 49.99` money in the default context,
     * `allocateWithRemainder(1, 2, 3, 4)` returns [`USD 4.99`, `USD 9.99`, `USD 14.99`, `USD 19.99`, `USD 0.03`]
     *
     * The resulting monies have the same context as this Money.
     *
     * @param int[] $ratios The ratios.
     *
     * @return Money[]
     *
     * @throws \InvalidArgumentException If called with invalid parameters.
     */
    public function allocateWithRemainder(int ...$ratios) : array
    {
        if (! $ratios) {
            throw new \InvalidArgumentException('Cannot allocateWithRemainder() an empty list of ratios.');
        }

        foreach ($ratios as $ratio) {
            if ($ratio < 0) {
                throw new \InvalidArgumentException('Cannot allocateWithRemainder() negative ratios.');
            }
        }

        $total = array_sum($ratios);

        if ($total === 0) {
            throw new \InvalidArgumentException('Cannot allocateWithRemainder() to zero ratios only.');
        }

        $monies = [];

        $remainder = $this;

        foreach ($ratios as $ratio) {
            $money = $this->multipliedBy($ratio)->quotient($total);
            $remainder = $remainder->minus($money);
            $monies[] = $money;
        }

        $monies[] = $remainder;

        return $monies;
    }

    /**
     * Splits this Money into a number of parts.
     *
     * If the division of this Money by the number of parts yields a remainder, its amount is split over the first
     * monies in the list, so that the total of the resulting monies is always equal to this Money.
     *
     * For example, given a `USD 100.00` money in the default context,
     * `split(3)` returns [`USD 33.34`, `USD 33.33`, `USD 33.33`]
     *
     * The resulting monies have the same context as this Money.
     *
     * @param int $parts The number of parts.
     *
     * @return Money[]
     *
     * @throws \InvalidArgumentException If called with invalid parameters.
     */
    public function split(int $parts) : array
    {
        if ($parts < 1) {
            throw new \InvalidArgumentException('Cannot split() into less than 1 part.');
        }

        return $this->allocate(...array_fill(0, $parts, 1));
    }

    /**
     * Splits this Money into a number of parts and a remainder.
     *
     * For example, given a `USD 100.00` money in the default context,
     * `splitWithRemainder(3)` returns [`USD 33.33`, `USD 33.33`, `USD 33.33`, `USD 0.01`]
     *
     * The resulting monies have the same context as this Money.
     *
     * @param int $parts The number of parts
     *
     * @return Money[]
     *
     * @throws \InvalidArgumentException If called with invalid parameters.
     */
    public function splitWithRemainder(int $parts) : array
    {
        if ($parts < 1) {
            throw new \InvalidArgumentException('Cannot splitWithRemainder() into less than 1 part.');
        }

        return $this->allocateWithRemainder(...array_fill(0, $parts, 1));
    }

    /**
     * Returns a Money whose value is the absolute value of this Money.
     *
     * The resulting Money has the same context as this Money.
     *
     * @return Money
     */
    public function abs() : Money
    {
        return new Money($this->amount->abs(), $this->currency, $this->context);
    }

    /**
     * Returns a Money whose value is the negated value of this Money.
     *
     * The resulting Money has the same context as this Money.
     *
     * @return Money
     */
    public function negated() : Money
    {
        return new Money($this->amount->negated(), $this->currency, $this->context);
    }

    /**
     * Converts this Money to another currency, using an exchange rate.
     *
     * By default, the resulting Money has the same context as this Money.
     * This can be overridden by providing a Context.
     *
     * For example, converting a default money of `USD 1.23` to `EUR` with an exchange rate of `0.91` and
     * RoundingMode::UP will yield `EUR 1.12`.
     *
     * @psalm-param RoundingMode::* $roundingMode
     *
     * @param Currency|string|int        $currency     The Currency instance, ISO currency code or ISO numeric currency code.
     * @param BigNumber|int|float|string $exchangeRate The exchange rate to multiply by.
     * @param Context|null               $context      An optional context.
     * @param int                        $roundingMode An optional rounding mode.
     *
     * @return Money
     *
     * @throws UnknownCurrencyException If an unknown currency code is given.
     * @throws MathException            If the exchange rate or rounding mode is invalid, or rounding is necessary.
     */
    public function convertedTo($currency, $exchangeRate, ?Context $context = null, int $roundingMode = RoundingMode::UNNECESSARY) : Money
    {
        if (! $currency instanceof Currency) {
            $currency = Currency::of($currency);
        }

        if ($context === null) {
            $context = $this->context;
        }

        $amount = $this->amount->toBigRational()->multipliedBy($exchangeRate);

        return self::create($amount, $currency, $context, $roundingMode);
    }

    /**
     * Formats this Money with the given NumberFormatter.
     *
     * Note that NumberFormatter internally represents values using floating point arithmetic,
     * so discrepancies can appear when formatting very large monetary values.
     *
     * @param \NumberFormatter $formatter The formatter to format with.
     *
     * @return string
     */
    public function formatWith(\NumberFormatter $formatter) : string
    {
        return $formatter->formatCurrency(
            $this->amount->toFloat(),
            $this->currency->getCurrencyCode()
        );
    }

    /**
     * Formats this Money to the given locale.
     *
     * Note that this method uses NumberFormatter, which internally represents values using floating point arithmetic,
     * so discrepancies can appear when formatting very large monetary values.
     *
     * @param string $locale           The locale to format to.
     * @param bool   $allowWholeNumber Whether to allow formatting as a whole number if the amount has no fraction.
     *
     * @return string
     */
    public function formatTo(string $locale, bool $allowWholeNumber = false) : string
    {
        /** @var \NumberFormatter|null $lastFormatter */
        static $lastFormatter = null;
        static $lastFormatterLocale;
        static $lastFormatterScale;

        if ($allowWholeNumber && ! $this->amount->hasNonZeroFractionalPart()) {
            $scale = 0;
        } else {
            $scale = $this->amount->getScale();
        }

        if ($lastFormatter !== null && $lastFormatterLocale === $locale) {
            $formatter = $lastFormatter;

            if ($lastFormatterScale !== $scale) {
                $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, $scale);
                $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $scale);

                $lastFormatterScale = $scale;
            }
        } else {
            $formatter = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);

            $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, $scale);
            $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $scale);

            $lastFormatter = $formatter;
            $lastFormatterLocale = $locale;
            $lastFormatterScale = $scale;
        }

        return $this->formatWith($formatter);
    }

    /**
     * @return RationalMoney
     */
    public function toRational() : RationalMoney
    {
        return new RationalMoney($this->amount->toBigRational(), $this->currency);
    }

    /**
     * Returns a non-localized string representation of this Money, e.g. "EUR 23.00".
     *
     * @return string
     */
    public function __toString() : string
    {
        return $this->currency . ' ' . $this->amount;
    }

    /**
     * @param Context $context The Context to check against this Money.
     * @param string  $method  The invoked method name.
     *
     * @return void
     *
     * @throws MoneyMismatchException If monies don't match.
     */
    protected function checkContext(Context $context, string $method) : void
    {
        if ($this->context != $context) { // non-strict equality on purpose
            throw MoneyMismatchException::contextMismatch($method);
        }
    }
}
