<?php
/**
 * This file is part of the PrestaShop\Decimal package
 *
 * @author    PrestaShop SA <contact@prestashop.com>
 * @license   https://opensource.org/licenses/MIT MIT License
 */

namespace PrestaShop\Decimal;

use InvalidArgumentException;
use PrestaShop\Decimal\Operation\Rounding;

/**
 * Decimal number.
 *
 * Allows for arbitrary precision math operations.
 */
class DecimalNumber
{
    /**
     * Indicates if the number is negative
     *
     * @var bool
     */
    private $isNegative = false;

    /**
     * Integer representation of this number
     *
     * @var string
     */
    private $coefficient = '';

    /**
     * Scientific notation exponent. For practical reasons, it's always stored as a positive value.
     *
     * @var int
     */
    private $exponent = 0;

    /**
     * Number constructor.
     *
     * This constructor can be used in two ways:
     *
     * 1) With a number string:
     *
     * ```php
     * (string) new Number('0.123456'); // -> '0.123456'
     * ```
     *
     * 2) With an integer string as coefficient and an exponent
     *
     * ```php
     * // 123456 * 10^(-6)
     * (string) new Number('123456', 6); // -> '0.123456'
     * ```
     *
     * Note: decimal positions must always be a positive number.
     *
     * @param string $number Number or coefficient
     * @param int|null $exponent [default=null] If provided, the number can be considered as the negative
     *                           exponent of the scientific notation, or the number of fractional digits
     */
    public function __construct($number, $exponent = null)
    {
        if (!is_string($number)) {
            throw new InvalidArgumentException(sprintf('Invalid type - expected string, but got (%s) "%s"', gettype($number), print_r($number, true)));
        }

        if (null === $exponent) {
            $decimalNumber = Builder::parseNumber($number);
            $number = $decimalNumber->getSign() . $decimalNumber->getCoefficient();
            $exponent = $decimalNumber->getExponent();
        }

        $this->initFromScientificNotation($number, $exponent);

        if ('0' === $this->coefficient) {
            // make sure the sign is always positive for zero
            $this->isNegative = false;
        }
    }

    /**
     * Returns the integer part of the number.
     * Note that this does NOT include the sign.
     *
     * @return string
     */
    public function getIntegerPart()
    {
        if ('0' === $this->coefficient) {
            return $this->coefficient;
        }

        if (0 === $this->exponent) {
            return $this->coefficient;
        }

        if ($this->exponent >= strlen($this->coefficient)) {
            return '0';
        }

        return substr($this->coefficient, 0, -$this->exponent);
    }

    /**
     * Returns the fractional part of the number.
     * Note that this does NOT include the sign.
     *
     * @return string
     */
    public function getFractionalPart()
    {
        if (0 === $this->exponent || '0' === $this->coefficient) {
            return '0';
        }

        if ($this->exponent > strlen($this->coefficient)) {
            return str_pad($this->coefficient, $this->exponent, '0', STR_PAD_LEFT);
        }

        return substr($this->coefficient, -$this->exponent);
    }

    /**
     * Returns the number of digits in the fractional part.
     *
     * @see self::getExponent() This method is an alias of getExponent().
     *
     * @return int
     */
    public function getPrecision()
    {
        return $this->getExponent();
    }

    /**
     * Returns the number's sign.
     * Note that this method will return an empty string if the number is positive!
     *
     * @return string '-' if negative, empty string if positive
     */
    public function getSign()
    {
        return $this->isNegative ? '-' : '';
    }

    /**
     * Returns the exponent of this number. For practical reasons, this exponent is always >= 0.
     *
     * This value can also be interpreted as the number of significant digits on the fractional part.
     *
     * @return int
     */
    public function getExponent()
    {
        return $this->exponent;
    }

    /**
     * Returns the raw number as stored internally. This coefficient is always an integer.
     *
     * It can be transformed to float by computing:
     * ```
     * getCoefficient() * 10^(-getExponent())
     * ```
     *
     * @return string
     */
    public function getCoefficient()
    {
        return $this->coefficient;
    }

    /**
     * Returns a string representation of this object
     *
     * @return string
     */
    public function __toString()
    {
        $output = $this->getSign() . $this->getIntegerPart();

        $fractionalPart = $this->getFractionalPart();

        if ('0' !== $fractionalPart) {
            $output .= '.' . $fractionalPart;
        }

        return $output;
    }

    /**
     * Returns the number as a string, with exactly $precision decimals
     *
     * Example:
     * ```
     * $n = new Number('123.4560');
     * (string) $n->round(1); // '123.4'
     * (string) $n->round(2); // '123.45'
     * (string) $n->round(3); // '123.456'
     * (string) $n->round(4); // '123.4560' (trailing zeroes are added)
     * (string) $n->round(5); // '123.45600' (trailing zeroes are added)
     * ```
     *
     * @param int $precision Exact number of desired decimals
     * @param string $roundingMode [default=Rounding::ROUND_TRUNCATE] Rounding algorithm
     *
     * @return string
     */
    public function toPrecision($precision, $roundingMode = Rounding::ROUND_TRUNCATE)
    {
        $currentPrecision = $this->getPrecision();

        if ($precision === $currentPrecision) {
            return (string) $this;
        }

        $return = $this;

        if ($precision < $currentPrecision) {
            $return = (new Operation\Rounding())->compute($this, $precision, $roundingMode);
        }

        if ($precision > $return->getPrecision()) {
            return
                $return->getSign()
                . $return->getIntegerPart()
                . '.'
                . str_pad($return->getFractionalPart(), $precision, '0')
            ;
        }

        return (string) $return;
    }

    /**
     * Returns the number as a string, with up to $maxDecimals significant digits.
     *
     * Example:
     * ```
     * $n = new Number('123.4560');
     * (string) $n->round(1); // '123.4'
     * (string) $n->round(2); // '123.45'
     * (string) $n->round(3); // '123.456'
     * (string) $n->round(4); // '123.456' (does not add trailing zeroes)
     * (string) $n->round(5); // '123.456' (does not add trailing zeroes)
     * ```
     *
     * @param int $maxDecimals Maximum number of decimals
     * @param string $roundingMode [default=Rounding::ROUND_TRUNCATE] Rounding algorithm
     *
     * @return string
     */
    public function round($maxDecimals, $roundingMode = Rounding::ROUND_TRUNCATE)
    {
        $currentPrecision = $this->getPrecision();

        if ($maxDecimals < $currentPrecision) {
            return (string) (new Operation\Rounding())->compute($this, $maxDecimals, $roundingMode);
        }

        return (string) $this;
    }

    /**
     * Returns this number as a positive number
     *
     * @return self
     */
    public function toPositive()
    {
        if (!$this->isNegative) {
            return $this;
        }

        return $this->invert();
    }

    /**
     * Returns this number as a negative number
     *
     * @return self
     */
    public function toNegative()
    {
        if ($this->isNegative) {
            return $this;
        }

        return $this->invert();
    }

    /**
     * Returns the computed result of adding another number to this one
     *
     * @param self $addend Number to add
     *
     * @return self
     */
    public function plus(self $addend)
    {
        return (new Operation\Addition())->compute($this, $addend);
    }

    /**
     * Returns the computed result of subtracting another number to this one
     *
     * @param self $subtrahend Number to subtract
     *
     * @return self
     */
    public function minus(self $subtrahend)
    {
        return (new Operation\Subtraction())->compute($this, $subtrahend);
    }

    /**
     * Returns the computed result of multiplying this number with another one
     *
     * @param self $factor
     *
     * @return self
     */
    public function times(self $factor)
    {
        return (new Operation\Multiplication())->compute($this, $factor);
    }

    /**
     * Returns the computed result of dividing this number by another one, with up to $precision number of decimals.
     *
     * A target maximum precision is required in order to handle potential infinite number of decimals
     * (e.g. 1/3 = 0.3333333...).
     *
     * If the division yields more decimal positions than the requested precision,
     * the remaining decimals are truncated, with **no rounding**.
     *
     * @param self $divisor
     * @param int $precision [optional] By default, up to Operation\Division::DEFAULT_PRECISION number of decimals
     *
     * @return self
     *
     * @throws Exception\DivisionByZeroException
     */
    public function dividedBy(self $divisor, $precision = Operation\Division::DEFAULT_PRECISION)
    {
        return (new Operation\Division())->compute($this, $divisor, $precision);
    }

    /**
     * Indicates if this number equals zero
     *
     * @return bool
     */
    public function equalsZero()
    {
        return '0' == $this->getCoefficient();
    }

    /**
     * Indicates if this number is greater than the provided one
     *
     * @param self $number
     *
     * @return bool
     */
    public function isGreaterThan(self $number)
    {
        return 1 === (new Operation\Comparison())->compare($this, $number);
    }

    /**
     * Indicates if this number is greater than zero
     *
     * @return bool
     */
    public function isGreaterThanZero()
    {
        return $this->isPositive() && !$this->equalsZero();
    }

    /**
     * Indicates if this number is greater or equal than zero
     *
     * @return bool
     */
    public function isGreaterOrEqualThanZero()
    {
        return $this->isPositive();
    }

    /**
     * Indicates if this number is greater or equal compared to the provided one
     *
     * @param self $number
     *
     * @return bool
     */
    public function isGreaterOrEqualThan(self $number)
    {
        return 0 <= (new Operation\Comparison())->compare($this, $number);
    }

    /**
     * Indicates if this number is lower than zero
     *
     * @return bool
     */
    public function isLowerThanZero()
    {
        return $this->isNegative() && !$this->equalsZero();
    }

    /**
     * Indicates if this number is lower or equal than zero
     *
     * @return bool
     */
    public function isLowerOrEqualThanZero()
    {
        return $this->isNegative() || $this->equalsZero();
    }

    /**
     * Indicates if this number is greater than the provided one
     *
     * @param self $number
     *
     * @return bool
     */
    public function isLowerThan(self $number)
    {
        return -1 === (new Operation\Comparison())->compare($this, $number);
    }

    /**
     * Indicates if this number is lower or equal compared to the provided one
     *
     * @param self $number
     *
     * @return bool
     */
    public function isLowerOrEqualThan(self $number)
    {
        return 0 >= (new Operation\Comparison())->compare($this, $number);
    }

    /**
     * Indicates if this number is positive
     *
     * @return bool
     */
    public function isPositive()
    {
        return !$this->isNegative;
    }

    /**
     * Indicates if this number is negative
     *
     * @return bool
     */
    public function isNegative()
    {
        return $this->isNegative;
    }

    /**
     * Indicates if this number equals another one
     *
     * @param self $number
     *
     * @return bool
     */
    public function equals(self $number)
    {
        return
            $this->isNegative === $number->isNegative
            && $this->coefficient === $number->getCoefficient()
            && $this->exponent === $number->getExponent()
        ;
    }

    /**
     * Returns the additive inverse of this number (that is, N * -1).
     *
     * @return static
     */
    public function invert()
    {
        // invert sign
        $sign = $this->isNegative ? '' : '-';

        return new static($sign . $this->getCoefficient(), $this->getExponent());
    }

    /**
     * Creates a new copy of this number multiplied by 10^$exponent
     *
     * @param int $exponent
     *
     * @return static
     */
    public function toMagnitude($exponent)
    {
        return (new Operation\MagnitudeChange())->compute($this, $exponent);
    }

    /**
     * Initializes the number using a coefficient and exponent
     *
     * @param string $coefficient
     * @param int $exponent
     */
    private function initFromScientificNotation($coefficient, $exponent)
    {
        if ($exponent < 0) {
            throw new InvalidArgumentException(sprintf('Invalid value for exponent. Expected a positive integer or 0, but got "%s"', $coefficient));
        }

        if (!preg_match("/^(?<sign>[-+])?(?<integerPart>\d+)$/", $coefficient, $parts)) {
            throw new InvalidArgumentException(sprintf('"%s" cannot be interpreted as a number', $coefficient));
        }

        $this->isNegative = ('-' === $parts['sign']);
        $this->exponent = (int) $exponent;
        // trim leading zeroes
        $this->coefficient = ltrim($parts['integerPart'], '0');

        // when coefficient is '0' or a sequence of '0'
        if ('' === $this->coefficient) {
            $this->exponent = 0;
            $this->coefficient = '0';

            return;
        }

        $this->removeTrailingZeroesIfNeeded();
    }

    /**
     * Removes trailing zeroes from the fractional part and adjusts the exponent accordingly
     */
    private function removeTrailingZeroesIfNeeded()
    {
        $exponent = $this->getExponent();
        $coefficient = $this->getCoefficient();

        // trim trailing zeroes from the fractional part
        // for example 1000e-1 => 100.0
        if (0 < $exponent && '0' === substr($coefficient, -1)) {
            $fractionalPart = $this->getFractionalPart();
            $trailingZeroesToRemove = 0;
            for ($i = $exponent - 1; $i >= 0; --$i) {
                if ('0' !== $fractionalPart[$i]) {
                    break;
                }
                ++$trailingZeroesToRemove;
            }

            if ($trailingZeroesToRemove > 0) {
                $this->coefficient = substr($coefficient, 0, -$trailingZeroesToRemove);
                $this->exponent = $exponent - $trailingZeroesToRemove;
            }
        }
    }
}
