<?php
/**
 * Normalized financial value.
 *
 * @package Carticy\Conversions\Domain
 */

declare(strict_types=1);

namespace Carticy\Conversions\Domain;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Stores an amount, currency, and authoritative source label.
 */
final class Money {

	/**
	 * Normalized decimal amount.
	 *
	 * @var string
	 */
	private string $amount;

	/**
	 * ISO 4217 currency code.
	 *
	 * @var string
	 */
	private string $currency;

	/**
	 * Named authoritative source, for example wc_order_total.
	 *
	 * @var string
	 */
	private string $source;

	/**
	 * Decimal precision.
	 *
	 * @var int
	 */
	private int $precision;

	/**
	 * Constructor.
	 *
	 * @param int|float|string $amount Amount from the authoritative source.
	 * @param string           $currency ISO 4217 currency code.
	 * @param string           $source Named source, for example wc_order_total.
	 * @param int              $precision Decimal precision.
	 *
	 * @throws \InvalidArgumentException When the source is empty or the currency is not a three-letter ISO code.
	 */
	public function __construct( $amount, string $currency, string $source, int $precision = 2 ) {
		if ( '' === trim( $source ) ) {
			throw new \InvalidArgumentException( 'Financial values must declare their authoritative source.' );
		}

		$normalized_currency = strtoupper( trim( $currency ) );
		if ( 1 !== preg_match( '/^[A-Z]{3}$/', $normalized_currency ) ) {
			throw new \InvalidArgumentException( 'Currency must be a three-letter ISO code.' );
		}

		$this->precision = max( 0, $precision );
		$this->amount    = $this->normalize_amount( $amount, $this->precision );
		$this->currency  = $normalized_currency;
		$this->source    = trim( $source );
	}

	/**
	 * Rebuild from serialized data.
	 *
	 * @param array<string, mixed> $data Serialized money data.
	 * @return self
	 */
	public static function from_array( array $data ): self {
		return new self(
			$data['amount'] ?? '0',
			(string) ( $data['currency'] ?? '' ),
			(string) ( $data['source'] ?? '' ),
			isset( $data['precision'] ) ? (int) $data['precision'] : 2
		);
	}

	/**
	 * Get normalized amount.
	 *
	 * @return string
	 */
	public function amount(): string {
		return $this->amount;
	}

	/**
	 * Get amount as float for provider layers that require JSON numbers.
	 *
	 * @return float
	 */
	public function amount_float(): float {
		return (float) $this->amount;
	}

	/**
	 * Get currency.
	 *
	 * @return string
	 */
	public function currency(): string {
		return $this->currency;
	}

	/**
	 * Get authoritative source label.
	 *
	 * @return string
	 */
	public function source(): string {
		return $this->source;
	}

	/**
	 * Serialize for storage.
	 *
	 * @return array<string, mixed>
	 */
	public function to_array(): array {
		return array(
			'amount'    => $this->amount,
			'currency'  => $this->currency,
			'source'    => $this->source,
			'precision' => $this->precision,
		);
	}

	/**
	 * Normalize a decimal string without converting through binary float first.
	 *
	 * WooCommerce exposes totals as decimal strings. Keeping normalization string
	 * based avoids the historical "0.00 is not empty" and float drift classes of
	 * amount bugs before provider layers convert to JSON numbers.
	 *
	 * @param int|float|string $amount Amount from the authoritative source.
	 * @param int              $precision Decimal precision.
	 * @return string
	 *
	 * @throws \InvalidArgumentException When the amount is not a plain decimal number.
	 */
	private function normalize_amount( $amount, int $precision ): string {
		$raw = trim( (string) $amount );
		if ( 1 !== preg_match( '/^([+-]?)(\d+)(?:\.(\d+))?$/', $raw, $matches ) ) {
			throw new \InvalidArgumentException( 'Financial amount must be a plain decimal number.' );
		}

		$sign     = '-' === $matches[1] ? '-' : '';
		$integer  = ltrim( $matches[2], '0' );
		$integer  = '' === $integer ? '0' : $integer;
		$fraction = $matches[3] ?? '';

		$kept        = 0 === $precision ? '' : str_pad( substr( $fraction, 0, $precision ), $precision, '0' );
		$round_digit = isset( $fraction[ $precision ] ) ? (int) $fraction[ $precision ] : 0;

		if ( $round_digit >= 5 ) {
			if ( 0 === $precision ) {
				$integer = $this->increment_numeric_string( $integer );
			} else {
				$kept = $this->increment_fraction( $kept, $integer );
			}
		}

		if ( '0' === $integer && ( '' === $kept || 0 === (int) $kept ) ) {
			$sign = '';
		}

		return 0 === $precision ? $sign . $integer : $sign . $integer . '.' . $kept;
	}

	/**
	 * Increment a numeric string.
	 *
	 * @param string $number Numeric string.
	 * @return string
	 */
	private function increment_numeric_string( string $number ): string {
		$carry = 1;
		$out   = '';

		for ( $index = strlen( $number ) - 1; $index >= 0; --$index ) {
			$digit = (int) $number[ $index ] + $carry;
			if ( $digit >= 10 ) {
				$digit = 0;
				$carry = 1;
			} else {
				$carry = 0;
			}
			$out = (string) $digit . $out;
		}

		return $carry ? '1' . $out : $out;
	}

	/**
	 * Increment a fixed-width fraction and carry to the integer when needed.
	 *
	 * @param string $fraction Fractional digits.
	 * @param string $integer Integer digits, mutated on carry.
	 * @return string
	 */
	private function increment_fraction( string $fraction, string &$integer ): string {
		$carry = 1;
		$out   = '';

		for ( $index = strlen( $fraction ) - 1; $index >= 0; --$index ) {
			$digit = (int) $fraction[ $index ] + $carry;
			if ( $digit >= 10 ) {
				$digit = 0;
				$carry = 1;
			} else {
				$carry = 0;
			}
			$out = (string) $digit . $out;
		}

		if ( $carry ) {
			$integer = $this->increment_numeric_string( $integer );
		}

		return $out;
	}
}
