<?php
/**
 * File Path: includes/helpers/class-mbs-formatting.php
 *
 * Formatting Helper — price / currency only.
 *
 * Remaining methods:
 *   zero_decimal_currencies() [private]
 *   is_zero_decimal_currency(), get_max_price(), get_currency_symbol()
 *   format_price(), price_for_csv(), format_date()
 *   sanitize_price(), clean_price()
 */

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

class MBS_Formatting { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/**
	 * Zero-decimal currency definitions.
	 *
	 * Returns an associative array of ISO-4217 currency code → maximum
	 * allowable price (integer).  The cap for each currency is set at the
	 * rough equivalent of what the USD 999.99 cap represents: "no real book
	 * costs this much."  Exchange rates are baked in at order-of-magnitude
	 * accuracy; they are intentionally conservative to prevent absurd entries
	 * without blocking legitimate specialty/academic titles.
	 *
	 * FV-015: Zero-decimal currencies must not accept decimal input and must
	 * use integer-only validation both here (PHP) and in the JS layer
	 * (MBS_PriceConfig.isZeroDecimal flag).
	 *
	 * Sources: Stripe zero-decimal currency list + common currencies not in Stripe.
	 *
	 * @return array<string,int>  Currency code → integer price cap.
	 */
	private static function zero_decimal_currencies(): array {
		return array(
			// East Asian
			'JPY' => 99999,    // Japanese Yen      — novel ¥700–¥2,000; cap ≈ $660
			'KRW' => 999999,   // South Korean Won  — novel ₩12,000–₩18,000; cap ≈ $720
			'TWD' => 9999,     // New Taiwan Dollar — novel NT$200–NT$600; cap ≈ $310
			// South / South-East Asian
			'IDR' => 9999999,  // Indonesian Rupiah — novel Rp80,000–Rp150,000; cap ≈ $620
			'VND' => 9999999,  // Vietnamese Dong   — novel ₫80,000–₫200,000; cap ≈ $390
			// European
			'HUF' => 99999,    // Hungarian Forint  — novel Ft2,000–Ft5,000; cap ≈ $270
			'ISK' => 99999,    // Icelandic Króna   — novel kr2,500–kr5,000; cap ≈ $710
			// Latin American
			'CLP' => 999999,   // Chilean Peso      — novel CLP5,000–CLP12,000; cap ≈ $990
			'PYG' => 9999999,  // Paraguayan Guaraní— novel Gs30,000–Gs80,000; cap ≈ $1,200
			// African / Other Franc zone
			'UGX' => 3999999,  // Ugandan Shilling  — cap ≈ $1,050
			'RWF' => 999999,   // Rwandan Franc     — cap ≈ $680
			'BIF' => 2999999,  // Burundian Franc   — cap ≈ $1,040
			'DJF' => 199999,   // Djiboutian Franc  — cap ≈ $1,120
			'GNF' => 9999999,  // Guinean Franc     — cap ≈ $1,160
			'KMF' => 499999,   // Comorian Franc    — cap ≈ $1,090
			'MGA' => 4999999,  // Malagasy Ariary   — cap ≈ $1,100
			'XAF' => 649999,   // CFA Franc BEAC    — cap ≈ $1,080
			'XOF' => 649999,   // CFA Franc BCEAO   — cap ≈ $1,080
			'XPF' => 119999,   // CFP Franc         — cap ≈ $1,060
		);
	}

	/**
	 * Returns true if the given ISO-4217 currency code is a zero-decimal currency.
	 *
	 * Zero-decimal currencies are stored and displayed as whole integers — e.g.
	 * ¥1500 not ¥1500.00.  Input validation must reject decimal values for these
	 * currencies (FV-015).
	 *
	 * @param  string $currency  ISO-4217 code (case-insensitive).
	 */
	public static function is_zero_decimal_currency( string $currency ): bool {
		return array_key_exists( strtoupper( $currency ), self::zero_decimal_currencies() );
	}

	/**
	 * Returns the maximum allowable price for a given currency.
	 *
	 * For zero-decimal currencies, returns the per-currency integer cap defined
	 * in zero_decimal_currencies().  For all other currencies the standard
	 * 999.99 cap (PRICE-V-3) applies.
	 *
	 * @param  string     $currency  ISO-4217 code (case-insensitive).
	 * @return int|float             Integer cap for zero-decimal; 999.99 otherwise.
	 */
	public static function get_max_price( string $currency ): int|float {
		$map = self::zero_decimal_currencies();
		$key = strtoupper( $currency );
		return $map[ $key ] ?? 999.99;
	}

	/**
	 * Currency-symbol map — single source of truth for display formatting.
	 *
	 * R-02 (2026-04-25): Promoted from private to public static and expanded
	 * to the full currency set supported by the plugin. Replaces the separate
	 * symbol map previously maintained in MBS_Tab_Formatting. Dollar-sign
	 * currencies use disambiguated prefixes (CA$, A$, HK$, MX$, S$, NZ$) for
	 * display accuracy — both front-end price formatting and admin UI now use
	 * this single map.
	 *
	 * @param string $currency ISO-4217 currency code (case-insensitive).
	 * @return string Currency symbol, or the currency code itself on no match.
	 */
	public static function get_currency_symbol( string $currency ): string {
		$map = array(
			// Dollar-sign currencies (disambiguated)
			'USD' => '$',    'CAD' => 'CA$',  'AUD' => 'A$',   'HKD' => 'HK$',  'MXN' => 'MX$',
			'SGD' => 'S$',   'NZD' => 'NZ$',
			// Euro & Sterling
			'EUR' => '€',    'GBP' => '£',
			// Asian
			'JPY' => '¥',    'CNY' => '¥',    'KRW' => '₩',    'INR' => '₹',    'HUF' => 'Ft',
			// European regional
			'CHF' => 'CHF',  'SEK' => 'kr',   'NOK' => 'kr',   'DKK' => 'kr',   'PLN' => 'zł',
			'CZK' => 'Kč',   'UAH' => '₴',    'TRY' => '₺',    'RUB' => '₽',
			// Americas
			'BRL' => 'R$',   'CRC' => '₡',    'PYG' => '₲',
			// Africa & Middle East
			'ZAR' => 'R',    'NGN' => '₦',    'ILS' => '₪',    'SAR' => '﷼',    'IRR' => '﷼',
			// South & Southeast Asia
			'IDR' => 'Rp',   'MYR' => 'RM',   'THB' => '฿',    'VND' => '₫',    'PHP' => '₱',
			'KZT' => '₸',    'LAK' => '₭',
			// Cryptocurrencies
			'BTC' => '₿',    'ETH' => 'Ξ',    'LTC' => 'Ł',    'DOGE' => 'Ð',
		);
		return $map[ strtoupper( $currency ) ] ?? $currency;
	}

	/**
	 * Format a stored price float for front-end display.
	 *
	 * PLG-026: Reads mbs_setting_formatting_price_format as a canonical
	 * identifier ('symbol_first' or 'symbol_last'). Legacy stored values
	 * were sample strings like "$12.99" or "12.99$" — backward compat is
	 * preserved by treating any value whose first character is a digit as
	 * 'symbol_last', matching the old heuristic.
	 *
	 * FV-008: Uses the currency symbol from get_currency_symbol() (not the
	 * ISO code), positioned per the user's price_format preference.
	 * FV-013: No thousands separator — max price is 999.99 (three digits),
	 * so a separator is never needed and locale-safe output is cleaner.
	 *
	 * @param string $currency ISO currency code override; reads setting when ''.
	 * @return string          Formatted string, e.g. "$9.99" or "9.99€".
	 */
	public static function format_price( float $price, string $currency = '' ): string {
		if ( '' === $currency ) {
			$currency = mbs_get_setting( 'general', 'currency', 'USD' );
		}

		$symbol = self::get_currency_symbol( $currency );

		// FV-015: Zero-decimal currencies are displayed as whole integers — no decimal
		// places and no trailing .00.  Standard currencies keep 2 decimal places.
		$formatted = self::is_zero_decimal_currency( $currency )
			? (string) (int) $price             // e.g. 1500 — no thousands separator, locale-safe
			: number_format_i18n( $price, 2 );  // e.g. 9,99 (locale decimal separator)

		// PLG-026: Canonical stored values are 'symbol_first' and 'symbol_last'.
		// Backward compat: old stored values were sample strings like "$12.99"
		// (symbol-first, first char not a digit) or "12.99$" (symbol-last, first
		// char is a digit). Both patterns resolve correctly here.
		$pref         = get_option( 'mbs_setting_formatting_price_format', 'symbol_first' );
		$symbol_last  = ( 'symbol_last' === $pref )
		             || ( '' !== $pref && ctype_digit( substr( $pref, 0, 1 ) ) );

		return $symbol_last ? $formatted . $symbol : $symbol . $formatted;
	}

	/**
	 * Format a stored price float for CSV export.
	 *
	 * PLG-028: CSV cells must contain bare numeric values — no currency symbol,
	 * no ISO code. Zero-decimal currencies are expressed as integers; standard
	 * currencies keep two decimal places. Callers must not use number_format()
	 * directly; route all CSV price output through this method so the rule is
	 * enforced in one place.
	 *
	 * @param string $currency ISO currency code override; reads setting when ''.
	 * @return string          Bare numeric string, e.g. "9.99" or "1500".
	 */
	public static function price_for_csv( float $price, string $currency = '' ): string {
		if ( '' === $currency ) {
			$currency = mbs_get_setting( 'general', 'currency', 'USD' );
		}
		return self::is_zero_decimal_currency( $currency )
			? number_format( $price, 0, '.', '' )
			: number_format( $price, 2, '.', '' );
	}

	public static function format_date( string $date_string ): string {
		if ( empty( $date_string ) ) {
			return '';
		}
		$format    = mbs_get_setting( 'formatting', 'date_format', 'F j, Y' );
		$timestamp = strtotime( $date_string );
		return date_i18n( $format, $timestamp );
	}

	/**
	 * Format a stored publication date (Y-m-d) as a locale-friendly string,
	 * treating midnight as local midnight in the MBS timezone setting.
	 *
	 * Without this helper, strtotime('2026-05-01') returns midnight UTC,
	 * which renders as "Apr 30, 2026" for UTC-offset timezones.
	 *
	 * @param string $ymd         Date stored in Y-m-d format. Empty string returns ''.
	 * @param string $date_format Optional. Date format for date_i18n(). Default 'M j, Y'.
	 * @return string Formatted date string, or empty string when $ymd is empty.
	 */
	public static function format_pub_date( string $ymd, string $date_format = 'M j, Y' ): string {
		if ( '' === $ymd ) {
			return '';
		}

		$tz_string = get_option( 'mbs_setting_general_timezone', '' );

		if ( '' === $tz_string ) {
			$tz_string = function_exists( 'wp_timezone_string' )
				? wp_timezone_string()
				: 'UTC';
		}

		// Validate the IANA string before using it; fall back to UTC on bad value.
		try {
			$tz = new DateTimeZone( $tz_string );
		} catch ( Exception $e ) {
			$tz = new DateTimeZone( 'UTC' );
		}

		try {
			$dt = new DateTimeImmutable( $ymd . ' 00:00:00', $tz );
		} catch ( Exception $e ) {
			return '';
		}

		return date_i18n( $date_format, $dt->getTimestamp() );
	}

	/**
	 * Sanitize and validate a raw price string for storage.
	 *
	 * PRICE-V-1: Strip a single leading/trailing currency symbol before evaluation.
	 * PRICE-V-2: Non-numeric, empty, or comma-containing input returns NULL.
	 *            Commas are rejected outright — "9,999.99" is an error, not 9999.99.
	 *            A comma indicates either a thousands separator (invalid per spec) or
	 *            a European decimal separator (also invalid — use a period).
	 * PRICE-V-3: Maximum value is currency-dependent.  Standard currencies cap at
	 *            999.99 (XXX.XX).  Zero-decimal currencies use per-currency integer
	 *            caps defined in zero_decimal_currencies() — see get_max_price().
	 * PRICE-V-4: Standard currencies auto-format to two decimal places on return.
	 *            Zero-decimal currencies are stored as whole integers (float with 0 dp).
	 * FV-015:    Zero-decimal currencies reject any decimal point in the input — e.g.
	 *            "1500.50" is invalid for JPY.
	 *
	 * @param  string     $price_raw  Raw user input, e.g. "$9.99", "1500", "¥1500".
	 * @param  string     $currency   ISO-4217 code; reads the plugin setting when ''.
	 * @return float|null             Sanitized float, or NULL on failure.
	 *                                Callers must surface a user-facing error when NULL
	 *                                is returned for a non-empty input.
	 */
	public static function sanitize_price( string $price_raw, string $currency = '' ): ?float {
		if ( '' === trim( $price_raw ) ) {
			return null;
		}

		if ( '' === $currency ) {
			$currency = mbs_get_setting( 'general', 'currency', 'USD' );
		}

		$is_zero_decimal = self::is_zero_decimal_currency( $currency );
		$max             = self::get_max_price( $currency );

		// PRICE-V-1: Strip exactly one leading or trailing currency symbol.
		// Recognized symbols: $ € £ ¥ ₹ ₩ R and multi-char prefixes handled below.
		$stripped = preg_replace( '/^[^\d\s,.]+|[^\d\s,.]+$/', '', trim( $price_raw ) );

		// PRICE-V-2a: Reject any comma — thousands separators and European decimals
		// are both disallowed.  "9,999.99" and "9,99" both return NULL.
		if ( str_contains( $stripped, ',' ) ) {
			return null;
		}

		// FV-015: Zero-decimal currencies must not contain a decimal point.
		if ( $is_zero_decimal && str_contains( $stripped, '.' ) ) {
			return null;
		}

		// PRICE-V-2b: After stripping symbol, only digits (and period for standard) allowed.
		$clean = preg_replace( '/[^0-9.]/', '', $stripped );
		if ( '' === $clean || ! is_numeric( $clean ) ) {
			return null;
		}

		// PRICE-V-3 / PRICE-V-4: Round and cap.
		if ( $is_zero_decimal ) {
			$price = (float) round( floatval( $clean ), 0 ); // whole integer, stored as float
			if ( $price > $max ) {
				return null;
			}
		} else {
			$price = round( floatval( $clean ), 2 );
			if ( $price >= 1000.00 ) { // standard cap: max 999.99
				return null;
			}
		}

		return $price;
	}

	/**
	 * Clean price for metabox save (alias for sanitize_price).
	 *
	 * @param  string $price_raw  Raw user input.
	 * @param  string $currency   ISO-4217 code; reads plugin setting when ''.
	 * @return float|null
	 */
	public static function clean_price( string $price_raw, string $currency = '' ): ?float {
		return self::sanitize_price( $price_raw, $currency );
	}

	/**
	 * Normalise a raw series-position string for export.
	 *
	 * Strips trailing zeros: '2.00' => '2', '2.50' => '2.5'.
	 * Returns empty string if blank or zero. Used by MBS_Book_Export_Data
	 * and both ONIX exporters (D-008).
	 *
	 * @param string $raw Raw value from mbs_series_position post_meta.
	 * @return string
	 */
	public static function format_series_number( string $raw ): string {
		if ( '' === $raw ) {
			return '';
		}
		$formatted = number_format( (float) $raw, 2, '.', '' );
		if ( '0.00' === $formatted ) {
			return '';
		}
		return rtrim( rtrim( $formatted, '0' ), '.' );
	}
}
