<?php

namespace Drupal\commerce_price;

/**
 * Provides helpers for bcmath-based arithmetic.
 *
 * The bcmath extension provides support for arbitrary precision arithmetic,
 * which does not suffer from the precision loses that make floating point
 * arithmetic unsafe for eCommerce.
 *
 * Important: All numbers must be passed as strings.
 */
final class Calculator {

  /**
   * Adds the second number to the first number.
   *
   * @param string $first_number
   *   The first number.
   * @param string $second_number
   *   The second number.
   * @param int $scale
   *   The maximum number of digits after the decimal place.
   *   Any digit after $scale will be truncated.
   *
   * @return string
   *   The result.
   */
  public static function add(string $first_number, string $second_number, int $scale = 6) : string {
    self::assertNumberFormat($first_number);
    self::assertNumberFormat($second_number);
    $result = bcadd($first_number, $second_number, $scale);
    return self::trim($result);
  }

  /**
   * Subtracts the second number from the first number.
   *
   * @param string $first_number
   *   The first number.
   * @param string $second_number
   *   The second number.
   * @param int $scale
   *   The maximum number of digits after the decimal place.
   *   Any digit after $scale will be truncated.
   *
   * @return string
   *   The result.
   */
  public static function subtract(string $first_number, string $second_number, int $scale = 6) : string {
    self::assertNumberFormat($first_number);
    self::assertNumberFormat($second_number);
    $result = bcsub($first_number, $second_number, $scale);
    return self::trim($result);
  }

  /**
   * Multiplies the first number by the second number.
   *
   * @param string $first_number
   *   The first number.
   * @param string $second_number
   *   The second number.
   * @param int $scale
   *   The maximum number of digits after the decimal place.
   *   Any digit after $scale will be truncated.
   *
   * @return string
   *   The result.
   */
  public static function multiply(string $first_number, string $second_number, int $scale = 6) : string {
    self::assertNumberFormat($first_number);
    self::assertNumberFormat($second_number);
    $result = bcmul($first_number, $second_number, $scale);
    return self::trim($result);
  }

  /**
   * Divides the first number by the second number.
   *
   * @param string $first_number
   *   The first number.
   * @param string $second_number
   *   The second number.
   * @param int $scale
   *   The maximum number of digits after the decimal place.
   *   Any digit after $scale will be truncated.
   *
   * @return string
   *   The result.
   */
  public static function divide(string $first_number, string $second_number, int $scale = 6) : string {
    self::assertNumberFormat($first_number);
    self::assertNumberFormat($second_number);
    $result = bcdiv($first_number, $second_number, $scale);
    return self::trim($result);
  }

  /**
   * Calculates the next highest whole value of a number.
   *
   * @param string $number
   *   A numeric string value.
   *
   * @return string
   *   The result.
   */
  public static function ceil(string $number) : string {
    if (self::compare($number, 0) == 1) {
      $result = bcadd($number, '1', 0);
    }
    else {
      $result = bcadd($number, '0', 0);
    }
    return $result;
  }

  /**
   * Calculates the next lowest whole value of a number.
   *
   * @param string $number
   *   The number.
   *
   * @return string
   *   The result.
   */
  public static function floor(string $number) : string {
    if (self::compare($number, 0) == 1) {
      $result = bcadd($number, '0', 0);
    }
    else {
      $result = bcadd($number, '-1', 0);
    }
    return $result;
  }

  /**
   * Rounds the given number.
   *
   * Replicates PHP's support for rounding to the nearest even/odd number
   * even if that number is decimal ($precision > 0).
   *
   * @param string $number
   *   The number.
   * @param int $precision
   *   The number of decimals to round to.
   * @param int $mode
   *   The rounding mode. One of the following constants: PHP_ROUND_HALF_UP,
   *   PHP_ROUND_HALF_DOWN, PHP_ROUND_HALF_EVEN, PHP_ROUND_HALF_ODD.
   *
   * @return string
   *   The rounded number.
   *
   * @throws \InvalidArgumentException
   *   Thrown when an invalid (non-numeric or negative) precision is given.
   */
  public static function round(string $number, int $precision = 0, int $mode = PHP_ROUND_HALF_UP) : string {
    self::assertNumberFormat($number);
    if (!is_numeric($precision) || $precision < 0) {
      throw new \InvalidArgumentException('The provided precision should be a positive number');
    }

    // Round the number in both directions (up/down) before choosing one.
    $rounding_increment = bcdiv('1', pow(10, $precision), $precision);
    if (self::compare($number, '0') == 1) {
      $rounded_up = bcadd($number, $rounding_increment, $precision);
    }
    else {
      $rounded_up = bcsub($number, $rounding_increment, $precision);
    }
    $rounded_down = bcsub($number, 0, $precision);
    // The rounding direction is based on the first decimal after $precision.
    $number_parts = explode('.', $number);
    $decimals = !empty($number_parts[1]) ? $number_parts[1] : '0';
    $relevant_decimal = $decimals[$precision] ?? 0;
    if ($relevant_decimal < 5) {
      $number = $rounded_down;
    }
    elseif ($relevant_decimal == 5) {
      if ($mode == PHP_ROUND_HALF_UP) {
        $number = $rounded_up;
      }
      elseif ($mode == PHP_ROUND_HALF_DOWN) {
        $number = $rounded_down;
      }
      elseif ($mode == PHP_ROUND_HALF_EVEN) {
        $integer = bcmul($rounded_up, pow(10, $precision), 0);
        $number = bcmod($integer, '2') == 0 ? $rounded_up : $rounded_down;
      }
      elseif ($mode == PHP_ROUND_HALF_ODD) {
        $integer = bcmul($rounded_up, pow(10, $precision), 0);
        $number = bcmod($integer, '2') != 0 ? $rounded_up : $rounded_down;
      }
    }
    elseif ($relevant_decimal > 5) {
      $number = $rounded_up;
    }

    return $number;
  }

  /**
   * Compares the first number to the second number.
   *
   * @param string $first_number
   *   The first number.
   * @param string $second_number
   *   The second number.
   * @param int $scale
   *   The maximum number of digits after the decimal place.
   *   Any digit after $scale will be truncated.
   *
   * @return int
   *   0 if both numbers are equal, 1 if the first one is greater, -1 otherwise.
   */
  public static function compare(string $first_number, string $second_number, int $scale = 6) : int {
    self::assertNumberFormat($first_number);
    self::assertNumberFormat($second_number);
    return bccomp($first_number, $second_number, $scale);
  }

  /**
   * Trims the given number.
   *
   * By default bcmath returns numbers with the number of digits according
   * to $scale. This means that bcadd('2', '2', 6) will return '4.00000'.
   * Trimming the number removes the excess zeroes.
   *
   * @param string $number
   *   The number to trim.
   *
   * @return string
   *   The trimmed number.
   */
  public static function trim(string $number) : string {
    if (str_contains($number, '.')) {
      // The number is decimal, strip trailing zeroes.
      // If no digits remain after the decimal point, strip it as well.
      $number = rtrim($number, '0');
      $number = rtrim($number, '.');
    }

    return $number;
  }

  /**
   * Assert that the given number is a numeric string value.
   *
   * @param string $number
   *   The number to check.
   *
   * @throws \InvalidArgumentException
   *   Thrown when the given number is not a numeric string value.
   */
  public static function assertNumberFormat($number) {
    if (is_float($number)) {
      throw new \InvalidArgumentException(sprintf('The provided value "%s" must be a string, not a float.', $number));
    }
    if (!is_numeric($number)) {
      throw new \InvalidArgumentException(sprintf('The provided value "%s" is not a numeric value.', $number));
    }
  }

}
