<?php

declare( strict_types=1 );

namespace DocketConnector\Common\Utilities;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Abort if this file is called directly.
}

if ( ! class_exists( Numbers::class ) ) {
	class Numbers {

		/**
		 * Round a numeric value up to the nearest decimal, such as up to the nearest 10 cents (without currency symbol).
		 *
		 * Any zero value will always return a '0' integer, never '0.0' or '0.00' as those are not valid floats, and we
		 * do not return strings.
		 * Look out for negative numbers. Rounding *up* '1.1' to zero places becomes '2', but rounding *up* '-1.9' to
		 * zero places becomes '-1'.
		 *
		 * @link http://php.net/manual/en/function.ceil.php#50448 Source of this code.
		 *
		 * @param string|float|int $value  A numeric value, whether a string, float, or integer.
		 *                                 If zero, result will be zero regardless of $places.
		 * @param int              $places The positive number of digits to round to, such as 1 for the nearest 10 cents
		 *                                 or 2 for the nearest penny.
		 *
		 * @return float|int Integer if an empty $value or $places is zero, else float.
		 */
		public function round_up( $value, int $places = 0 ) {
			$value = (float) $value;

			// Avoid dividing by zero.
			if ( empty( $value ) ) {
				return 0;
			}

			$places = absint( $places );

			$multiplier = pow( 10, $places );

			$result = ceil( $value * $multiplier ) / $multiplier;

			// Rounding to zero places expects integer (e.g. '18'), not float (e.g. '18.0').
			if ( 0 === $places ) {
				return (int) $result;
			}

			return $result;
		}

		/**
		 * Given a number, round up to the given integer interval.
		 *
		 * Useful to round up to the next 15 minutes, such as rounding 63 minutes up to 75 minutes (60 minutes + 15 minutes).
		 *
		 * @param int|float|string $value    The integer, float, or numeric string to round up to the next interval.
		 * @param int              $interval The interval to round up to, such as 15. Set to 1 to get the same thing as just
		 *                                   using ceil().
		 *
		 * @return int
		 */
		public function round_up_to_next( $value = 0, int $interval = 0 ): int {
			if (
				empty( $value )
				|| ! is_numeric( $value )
				|| ! is_int( $interval )
				|| 0 >= $interval
			) {
				return 0;
			}

			$result = $interval * ceil( $value / $interval );

			return (int) round( $result );
		}

		/**
		 * Given a numeric value, round to the specified places and return an unformatted string.
		 *
		 * Useful for consistently converting numeric values to zero-filled decimals and avoiding floats to 17 length
		 * strings via json_encode().
		 * Example: A string of '2,402.4950' will become '2402.50' even though is_numeric() is false for this string.
		 * Warning!!! Don't use this function blindly, such as via an array callback (e.g. zip code of '12345' would
		 * become '12345.00').
		 *
		 * @param mixed  $value                If is_numeric() or is_string() that is_numeric(), it will be processed,
		 *                                     else it will be unchanged.
		 * @param int    $places               The positive number of digits to round to, such as 1 for the nearest 10
		 *                                     cents or 2 for the nearest penny.
		 * @param string $decimal_separator    The decimal separator (radix) character, such as a period for the USA or
		 *                                     a comma. Affects processing the input as well as the output!
		 * @param string $thousands_separator  The thousands group separator (e.g. inverse of the decimal separator or
		 *                                     a space), to remove from a numeric string.
		 *
		 * @return mixed The string result if processed, else the original, unmodified value.
		 */
		public function if_numeric_round_then_unformatted_string(
			$value,
			int $places,
			string $decimal_separator = '.',
			string $thousands_separator = ','
		) {
			$places = absint( $places );

			if ( is_numeric( $value ) ) {
				// number_format() cannot be passed a string.
				return number_format( (float) $value, $places, $decimal_separator, '' );
			} elseif ( is_string( $value ) ) {
				/**
				 * A string like '4,321.00' does not satisfy is_numeric(), but it would if it didn't have the thousands
				 * separator. So remove all thousands to avoid malformed value passed to number format, then try again.
				 */
				$try = str_replace( $thousands_separator, '', $value );

				/**
				 * Only replace the first decimal separator with a period, since the presence of 2 would make it non-
				 * numeric, and is_numeric() only works if the radix is a period, not a comma.
				 */
				$first = strpos( $try, $decimal_separator );
				if ( false !== $first ) {
					$try = substr_replace( $try, '.', $first, strlen( $decimal_separator ) );
				}

				if ( is_numeric( $try ) ) {
					return number_format( (float) $try, $places, $decimal_separator, '' );
				}
			}

			// Return unmodified.
			return $value;
		}

		/**
		 * Convert an exponential notation (E-notation) value to its full string value, without changing precision, just
		 * moving the decimal place.
		 *
		 * If the string is not in e-notation (requiring 'E' or 'e'), will return null.
		 * Examples:
		 * `1.2000000992884E-10` becomes '0.00000000012000000992884'.
		 * float(1.2000000992884e6) is within PHP's precision so is actually passed as `1200000.0992884` and therefore
		 * not e-notation and therefore returns null. But the same value as a string will return '1200000.0992884'.
		 *
		 * @link https://www.calculator.net/scientific-notation-calculator.html
		 *
		 * @param mixed $value
		 *
		 * @return string|null Full, expanded string value of the numeric value that was in e-notation, else null.
		 */
		public function convert_exponential_notation_to_string( $value ): ?string {
			// Converting a short e-notation string to a float may turn it into not having e-notation at all.
			$try = (float) $value;

			$try = strtolower( (string) $try );

			$try = str_replace( '.', '', $try );

			$position = strpos( $try, 'e' );

			// Avoid fatal if not an exponential number string.
			if ( false === $position ) {
				return null;
			}

			[ $num, $exp ] = explode( 'e', $try );

			$exp = (int) $exp;

			// Return null to indicate it did not have an exponent.
			if ( $exp === 0 ) {
				return null;
			}

			if ( $exp < 0 ) {
				$count = absint( $exp ) - 1;
				$count = '%0' . $count . 'd';

				$padding = sprintf( $count, '' );

				return '0.' . $padding . $num;
			}

			// $exp > 0
			$count   = $exp - $position + 1;
			$count   = '%0' . $count . 'd';
			$padding = sprintf( $count, '' );

			return $num . $padding;
		}

		/**
		 * Given a numeric value, count the number of decimals.
		 *
		 * Note that floats cannot have padded zeros in PHP, but strings can. Example: float(1.000) will return `0`, but
		 * '1.000' will return `3`.
		 * Handles e-notation, e.g. `1.2000000992884e6` will return `7`.
		 * Non-numeric values will return null.
		 *
		 * @param mixed $value
		 *
		 * @return int|null The number of decimal places for a numeric value, else null if non-numeric.
		 */
		public function get_count_of_decimal_places( $value ): ?int {
			if ( ! is_numeric( $value ) ) {
				return null;
			}

			if ( is_int( $value ) ) {
				return 0;
			}

			if ( is_float( $value ) ) {
				$exp = $this->convert_exponential_notation_to_string( $value );

				if ( ! is_null( $exp ) ) {
					$value = $exp;
				}

				$value = (string) $value;
				$value = rtrim( $value, '0' );
			}

			$decimal_and_after = strrchr( (string) $value, '.' );

			if ( false === $decimal_and_after ) {
				return 0;
			}

			// Count the string length after ignoring initial decimal.
			return strlen( substr( $decimal_and_after, 1 ) );
		}

		/**
		 * Convert a numeric with many decimals to have fewer decimals cast to string.
		 *
		 * Useful for when json_encode() causes floats to be long string values (e.g. 17 digit precision) but wanting to
		 * skip processing all numerics (e.g. zip codes).
		 * Removes all thousands separators from final result. Example: A string of '2,402.4950' will become '2402.50'
		 * if changing from 3 decimals to 2 decimals.
		 *
		 * @param mixed  $value
		 * @param int    $from_decimals        The number of digits required in order to be processed (e.g. 3).
		 * @param int    $to_decimals          The number of digits to be changed to (e.g. 2).
		 * @param string $decimal_separator    The decimal separator (radix) character, such as a period for the USA or
		 *                                     a comma.
		 * @param string $thousands_separator  The thousands group separator (e.g. inverse of the decimal separator or
		 *                                     a space), to remove from a numeric string before processing.
		 *
		 * @return mixed A numeric string without thousands separators or the original, unmodified value if skipped.
		 */
		public function reduce_decimal_places(
			$value,
			int $from_decimals,
			int $to_decimals = 2,
			string $decimal_separator = '.',
			string $thousands_separator = ','
		) {
			// Bail if From and To aren't right.
			if (
				$from_decimals < 1
				|| $to_decimals < 0
				|| $from_decimals <= $to_decimals
			) {
				return $value;
			}

			if ( is_string( $value ) ) {
				// A string like '4,321.00' does not satisfy is_numeric(), but it would if it didn't have the thousands separator.
				// Remove all thousands to avoid malformed value passed to number format.
				$try = str_replace( $thousands_separator, '', $value );

				// Only replace first decimal separator with a period, since 2 periods would make is_numeric() false.
				$first = strpos( $try, $decimal_separator );
				if ( $first !== false ) {
					$try = substr_replace( $try, '.', $first, strlen( $decimal_separator ) );
				}

				if ( is_numeric( $try ) ) {
					$exp = $this->convert_exponential_notation_to_string( $try );

					if( ! is_null( $exp ) ) {
						$try = $exp;
					}

					// Bail if length is insufficient to process.
					if ( $from_decimals > $this->get_count_of_decimal_places( $try ) ) {
						return $value;
					}

					// number_format() cannot be passed a string.
					return number_format( (float) $try, $to_decimals, $decimal_separator, '' );
				}
			} elseif ( is_numeric( $value ) ) {
				$try = $this->convert_exponential_notation_to_string( $value );

				if( is_null( $try ) ) {
					$try = $value;
				}

				// Bail if length is insufficient to process.
				if ( $from_decimals > $this->get_count_of_decimal_places( $try ) ) {
					return $value;
				}

				return number_format( (float) $try, $to_decimals, $decimal_separator, '' );
			}

			// Original, unmodified value.
			return $value;
		}
	}
}
