<?php

namespace XF;

use XF\Db\AbstractAdapter;
use XF\Entity\User;
use XF\Repository\IconRepository;

use function array_key_exists, count, floatval, is_array, is_null;

class Language implements \ArrayAccess
{
	protected $id;
	protected $options = [
		'title' => '',
		'language_code' => '',
		'date_format' => 'M j, Y',
		'date_short_format' => 'M \'y',
		'date_short_recent_format' => 'M j Y',
		'time_format' => 'g:i A',
		'currency_format' => '{symbol}{value}',
		'decimal_point' => '.',
		'thousands_separator' => ',',
		'text_direction' => 'LTR',
		'week_start' => 0,
		'label_separator' => ':',
		'comma_separator' => ', ',
		'ellipsis' => '...',
		'parenthesis_open' => '(',
		'parenthesis_close' => ')',
		'user_selectable' => true,
	];

	protected static $modifierMap = [
		':' => 'label_separator',
		',' => 'comma_separator',
		'...' => 'ellipsis',
		'(' => 'parenthesis_open',
		')' => 'parenthesis_close',
	];

	/**
	 * @var AbstractAdapter
	 */
	protected $db;

	protected $groupPath;

	/**
	 * @var array
	 */
	protected $phraseCache = [];
	protected $phrasesToLoad = [];
	protected $groupsCached = [];

	/**
	 * @var \DateTime
	 */
	protected $date;

	protected $dayStartTimestamps = null;

	/**
	 * Translate a numeric day of the week to representation that will be used in phrases.
	 *
	 * @var array
	 */
	protected $dowTranslation = [
		0 => 'sunday',
		1 => 'monday',
		2 => 'tuesday',
		3 => 'wednesday',
		4 => 'thursday',
		5 => 'friday',
		6 => 'saturday',
	];

	public function __construct($id, array $options, AbstractAdapter $db, $groupPath, ?array $phrases = null)
	{
		if (isset($options['phrase_cache']))
		{
			$cache = $options['phrase_cache'];
			if (is_array($cache) && $phrases === null)
			{
				$phrases = $cache;
			}
			unset($options['phrase_cache']);
		}

		$this->id = $id;
		$this->options = array_merge($this->options, $options);
		$this->options['language_code'] = $this->getLanguageCode();

		$this->db = $db;
		$this->groupPath = $groupPath;

		if (is_array($phrases))
		{
			$this->phraseCache = $phrases;
		}

		$this->date = new \DateTime('now', new \DateTimeZone('UTC'));
	}

	public function getId()
	{
		return $this->id;
	}

	public function phrase($name, array $params = [], $preLoad = true, $allowHtml = true)
	{
		$effectiveName = $this->getEffectivePhraseName($name);

		if ($preLoad
			&& !isset($this->phraseCache[$effectiveName])
			&& !isset($this->phrasesToLoad[$effectiveName])
			&& strpos($effectiveName, '.') === false
		)
		{
			$this->phrasesToLoad[$effectiveName] = true;
		}

		return new Phrase($this, $name, $params, $allowHtml);
	}

	/**
	 * @param string $name
	 * @param array<string, string|int|float> $params
	 * @param 'html'|'raw'|'js'|'json'|'htmljs'|'datauri' $context
	 * @param array{fallback?: string|null, fallbackRaw?: bool, nameOnInvalid?: bool} $options
	 *
	 * @return string
	 */
	public function renderPhrase($name, array $params = [], $context = 'html', array $options = [])
	{
		$options = array_replace([
			'fallback' => null,
			'fallbackRaw' => false,
			'nameOnInvalid' => true,
		], $options);

		$name = $this->getEffectivePhraseName($name, $prefixes, $suffixes);

		$text = $this->getPhraseText($name);
		if ($text === false)
		{
			// phrase not found
			if ($options['fallback'] !== null)
			{
				if ($options['fallbackRaw'])
				{
					$text = $options['fallback'];
				}
				else
				{
					$text = \XF::escapeString($options['fallback'], $context);
				}
			}
			else if ($options['nameOnInvalid'])
			{
				$text = \XF::escapeString($name, $context);
			}
			else
			{
				// if not returning anything, don't apply any prefixes/suffixes
				return '';
			}
		}
		else
		{
			$iconRegex = IconRepository::ICON_PHRASE_REGEX;
			$text = preg_replace_callback(
				"/{$iconRegex}/i",
				function (array $icon): string
				{
					$iconClasses = $icon['variant'] . ' fa-' . $icon['name'];
					return \XF::app()->iconRenderer()->render($iconClasses);
				},
				$text
			);

			if ($params)
			{
				$text = preg_replace_callback('/\{([a-z0-9_-]+)\}/i', function (array $match) use ($params, $context)
				{
					$paramName = $match[1];

					if (!array_key_exists($paramName, $params))
					{
						return $match[0];
					}

					$param = $params[$paramName];
					if ($param instanceof Phrase)
					{
						return $param->render($context);
					}
					else
					{
						return \XF::escapeString($param, $context);
					}
				}, $text);
			}
		}

		if ($prefixes)
		{
			$text = implode('', $prefixes) . $text;
		}
		if ($suffixes)
		{
			// we process these right to left so invert them
			$suffixes = array_reverse($suffixes);
			$text .= implode('', $suffixes);
		}

		return $text;
	}

	protected function getEffectivePhraseName(string $name, &$prefixes = [], &$suffixes = []): string
	{
		// Process phrase name prefixes/suffixes.
		// Note that there is very similar code in XF\Template\Compiler\Func\Phrase. It should correspond.
		$prefixes = [];
		$suffixes = [];

		if ($name[0] == '(')
		{
			$prefixes[] = $this->options['parenthesis_open'];
			$name = substr($name, 1);
		}

		do
		{
			$matchedSuffix = false;

			if (substr($name, -3) == '...')
			{
				$suffixes[] = $this->options['ellipsis'];
				$name = substr($name, 0, -3);
				$matchedSuffix = true;
			}
			else
			{
				$lastChar = substr($name, -1);
				switch ($lastChar)
				{
					case ':':
					case ',':
					case ')':
					case '(':
						if (isset(self::$modifierMap[$lastChar]))
						{
							$suffixes[] = $this->options[self::$modifierMap[$lastChar]];
						}
						$matchedSuffix = true;
						$name = substr($name, 0, -1);
						break;
				}
			}
		}
		while ($matchedSuffix);

		return $name;
	}

	public function getPhraseText($name)
	{
		if (isset($this->phraseCache[$name]))
		{
			return $this->phraseCache[$name];
		}

		$parts = explode('.', $name, 2);
		if (isset($parts[1]) && $this->loadPhraseGroup($parts[0]))
		{
			// group has been cached so everything should be in it - if it's not, it's invalid
			if (!isset($this->phraseCache[$name]))
			{
				$this->phraseCache[$name] = false;
			}

			return $this->phraseCache[$name];
		}

		$this->phrasesToLoad[$name] = true;
		$this->loadPhrases();

		if (!isset($this->phraseCache[$name]))
		{
			$this->phraseCache[$name] = false;
		}

		return $this->phraseCache[$name];
	}

	protected function loadPhraseGroup($group)
	{
		if (isset($this->groupsCached[$group]))
		{
			return $this->groupsCached[$group];
		}

		if (preg_match('/[^a-z0-9_]/i', $group))
		{
			throw new \InvalidArgumentException("Phrase group $group is not a valid format");
		}

		$file = $this->groupPath . "/l$this->id/$group.php";

		try
		{
			$phrases = @include $file;
		}
		catch (\Throwable $e)
		{
			$phrases = false;
		}

		if (is_array($phrases))
		{
			// give existing cache precedence to help avoid stale recompiles
			$this->phraseCache = array_merge($phrases, $this->phraseCache);
			$this->groupsCached[$group] = true;
		}
		else
		{
			$this->groupsCached[$group] = false;
		}

		return $this->groupsCached[$group];
	}

	protected function getPhraseCacheRaw($name)
	{
		if (isset($this->phraseCache[$name]))
		{
			return $this->phraseCache[$name];
		}
		else
		{
			return $name;
		}
	}

	public function cachePhrase($name, $value)
	{
		$this->phraseCache[$name] = $value;
	}

	public function cachePhrases(array $phrases)
	{
		$this->phraseCache = array_merge($this->phraseCache, $phrases);
	}

	public function uncachePhrase($name)
	{
		if (strpos($name, '.') === false)
		{
			unset($this->phraseCache[$name]);
		}
	}

	public function isGroupCached($group)
	{
		return isset($this->groupsCached[$group]);
	}

	public function uncacheGroup($group)
	{
		unset($this->groupsCached[$group]);
	}

	public function requirePhrases(array $phrases)
	{
		foreach ($phrases AS $name)
		{
			if (!isset($this->phraseCache[$name])
				&& !isset($this->phrasesToLoad[$name])
				&& strpos($name, '.') === false
			)
			{
				$this->phrasesToLoad[$name] = true;
			}
		}
	}

	protected function loadPhrases()
	{
		if (!$this->phrasesToLoad)
		{
			return;
		}

		$phrases = $this->db->fetchPairs("
			SELECT title, phrase_text
			FROM xf_phrase_compiled
			WHERE language_id = ?
				AND title IN (" . $this->db->quote(array_keys($this->phrasesToLoad)) . ")
		", $this->id);

		foreach ($phrases AS $title => $text)
		{
			$this->phraseCache[$title] = $text;
		}

		$this->phrasesToLoad = [];
	}

	public function getTitle()
	{
		return $this->options['title'];
	}

	public function getLanguageCode()
	{
		return $this->options['language_code'];
	}

	public function getTextDirection()
	{
		return $this->options['text_direction'];
	}

	public function isRtl()
	{
		return ($this->options['text_direction'] == 'RTL');
	}

	public function isUsable(User $user)
	{
		if ($this->options['user_selectable'])
		{
			return true;
		}

		return $user->is_admin ? true : false;
	}

	#[\ReturnTypeWillChange]
	public function offsetGet($key)
	{
		return $this->options[$key];
	}

	public function __get($key)
	{
		return $this->offsetGet($key);
	}

	public function offsetExists($key): bool
	{
		return isset($this->options[$key]);
	}

	public function __isset($key)
	{
		return $this->offsetExists($key);
	}

	public function offsetSet($key, $value): void
	{
		throw new \LogicException("Language object cannot be written to.");
	}

	public function offsetUnset($key): void
	{
		throw new \LogicException("Language object cannot be written to.");
	}

	public function setTimeZone($tz)
	{
		if (!($tz instanceof \DateTimeZone))
		{
			try
			{
				$tz = new \DateTimeZone($tz);
			}
			catch (\Exception $e)
			{
				return false;
			}
		}

		$this->date->setTimezone($tz);
		return true;
	}

	/**
	 * @return \DateTimeZone
	 */
	public function getTimeZone()
	{
		return $this->date->getTimezone();
	}

	protected function formatDateTime(\DateTime $date, $format)
	{
		if (!$date)
		{
			$date = $this->date;
		}

		$dateParts = explode('|', $date->format('j|w|W|n|Y|G|i|s|S'));
		[$dayOfMonth, $dayOfWeek, $weekOfYear, $month, $year, $hour, $minute, $second, $ordinalSuffix] = $dateParts;

		$output = '';

		$formatters = str_split($format);
		$formatterCount = count($formatters);
		for ($i = 0; $i < $formatterCount; $i++)
		{
			$identifier = $formatters[$i];

			switch ($identifier)
			{
				// day of month
				case 'd': $output .= sprintf('%02d', $dayOfMonth); break;
				case 'j': $output .= $dayOfMonth; break;

				// day of week
				case 'D': $output .= $this->getPhraseCacheRaw('day_' . $this->dowTranslation[$dayOfWeek] . '_short'); break;
				case 'l': $output .= $this->getPhraseCacheRaw('day_' . $this->dowTranslation[$dayOfWeek]); break;

				// week
				case 'w': $output .= $dayOfWeek; break;
				case 'W': $output .= $weekOfYear; break;

				// month
				case 'm': $output .= sprintf('%02d', $month); break;
				case 'n': $output .= $month; break;
				case 'F': $output .= $this->getPhraseCacheRaw('month_' . $month); break;
				case 'M': $output .= $this->getPhraseCacheRaw('month_' . $month . '_short'); break;

				// year
				case 'Y': $output .= $year; break;
				case 'y': $output .= substr($year, 2); break;

				// am/pm
				case 'a': $output .= $this->getPhraseCacheRaw(($hour >= 12 ? 'time_pm_lower' : 'time_am_lower')); break;
				case 'A': $output .= $this->getPhraseCacheRaw(($hour >= 12 ? 'time_pm_upper' : 'time_am_upper')); break;

				// hour
				case 'H': $output .= sprintf('%02d', $hour); break;
				case 'h': $output .= sprintf('%02d', $hour % 12 ?: 12); break;
				case 'G': $output .= $hour; break;
				case 'g': $output .= ($hour % 12 ?: 12); break;

				// minute
				case 'i': $output .= $minute; break;

				// second
				case 's': $output .= $second; break;

				// ordinal
				case 'S': $output .= $ordinalSuffix; break;

				case '\\':
					$i++;
					if ($i < $formatterCount)
					{
						$output .= $formatters[$i];
					}
					break;

				// fallback to PHP formatter directly - shouldn't be used regularly
				case 'N':
				case 'z':
				case 't':
				case 'L':
				case 'o':
				case 'B':
				case 'u':
				case 'v':
				case 'e':
				case 'I':
				case 'O':
				case 'P':
				case 'T':
				case 'Z':
				case 'c':
				case 'r':
				case 'U':
					$output .= $date->format($identifier);
				break;

				// anything else is printed
				default: $output .= $identifier;
			}
		}

		return $output;
	}

	public function date($timestamp, $format = null)
	{
		if ($timestamp instanceof \DateTime)
		{
			$date = $timestamp;
		}
		else
		{
			$timestamp = $timestamp ?: 0;
			$date = $this->date->setTimestamp($timestamp);
		}

		switch ($format)
		{
			case 'year':
				$dateFormat = 'Y';
				break;

			case 'monthDay':
				$dateFormat = 'F j';
				break;

			case 'picker':
				$dateFormat = 'Y-m-d';
				break;

			case 'absolute':
			case '':
				$dateFormat = $this->options['date_format'];
				break;

			default:
				$dateFormat = $format;
		}

		return $this->formatDateTime($date, $dateFormat);
	}

	public function time($timestamp, $format = null)
	{
		if ($timestamp instanceof \DateTime)
		{
			$date = $timestamp;
		}
		else
		{
			$date = $this->date->setTimestamp($timestamp);
		}

		switch ($format)
		{
			case 'absolute':
			case '':
				$dateFormat = $this->options['time_format'];
				break;

			default:
				$dateFormat = $format;
		}

		return $this->formatDateTime($date, $dateFormat);
	}

	public function dateTime($timestamp)
	{
		[$date, $time, $short] = $this->getDateTimeParts($timestamp);
		return $this->getDateTimeOutput($date, $time);
	}

	public function getDateTimeParts($timestamp)
	{
		if ($timestamp instanceof \DateTime)
		{
			$date = $timestamp;
			$timestamp = $date->format('U');
		}
		else
		{
			$date = $this->date->setTimestamp($timestamp);
		}

		$dateTimeFormat = $this->options['date_format'] . '|' . $this->options['time_format'];

		$output = explode('|', $this->formatDateTime($date, $dateTimeFormat));

		$output[] = $this->getShortDate($timestamp);

		return $output;
	}

	public function getDateTimeOutput($date, $time)
	{
		return strtr($this->getPhraseCacheRaw('date_x_at_time_y'), [
			'{date}' => $date,
			'{time}' => $time,
		]);
	}

	protected function getRelativeDateTimeInternal($timestamp, $date, $time, $getFullDate = false)
	{
		$timeRef = $this->getDayStartTimestamps();

		$dateObj = new \DateTime('@' . $timestamp);
		$dateObj->setTimezone($this->getTimeZone());

		$interval = $timeRef['now'] - $timestamp;

		if ($interval < -2)
		{
			// future date
			$futureInterval = $timestamp - $timeRef['now'];

			if ($futureInterval < 60)
			{
				return $this->getPhraseCacheRaw('in_a_moment');
			}
			else if ($futureInterval < 120)
			{
				return $this->getPhraseCacheRaw('in_a_minute');
			}
			else if ($futureInterval < 3600)
			{
				return strtr($this->getPhraseCacheRaw('in_x_minutes'), [
					'{minutes}' => floor($futureInterval / 60),
				]);
			}
			else if ($timestamp < $timeRef['tomorrow'])
			{
				// today
				return strtr($this->getPhraseCacheRaw('later_today_at_x'), [
					'{time}' => $time,
				]);
			}
			else if ($timestamp < $dateObj->setTimestamp($timeRef['tomorrow'])->modify('+1 day')->format('U')) // day after tomorrow
			{
				// tomorrow
				return strtr($this->getPhraseCacheRaw('tomorrow_at_x'), [
					'{time}' => $time,
				]);
			}
			else if ($futureInterval < ($dateObj->setTimestamp(\XF::$time)->modify('+1 week')->format('U') - \XF::$time)) // one week in the future
			{
				// after tomorrow but within the next week
				return $this->getDateTimeOutput($date, $time);
			}
			else if ($getFullDate)
			{
				// more than a week in the future
				return $this->getDateTimeOutput($date, $time);
			}
			else
			{
				// after the next 7 days
				return $date;
			}
		}
		else if ($interval <= 60)
		{
			return $this->getPhraseCacheRaw('a_moment_ago');
		}
		else if ($interval <= 120)
		{
			return $this->getPhraseCacheRaw('one_minute_ago');
		}
		else if ($interval < 3600)
		{
			return strtr($this->getPhraseCacheRaw('x_minutes_ago'), [
				'{minutes}' => floor($interval / 60),
			]);
		}
		else if ($timestamp >= $timeRef['today'])
		{
			return strtr($this->getPhraseCacheRaw('today_at_x'), [
				'{time}' => $time,
			]);
		}
		else if ($timestamp >= $timeRef['yesterday'])
		{
			return strtr($this->getPhraseCacheRaw('yesterday_at_x'), [
				'{time}' => $time,
			]);
		}
		else if ($timestamp >= $timeRef['week'])
		{
			$dow = $dateObj->setTimestamp($timestamp)->format('w');
			$day = $this->getPhraseCacheRaw('day_' . $this->dowTranslation[$dow]);

			return strtr($this->getPhraseCacheRaw('day_x_at_time_y'), [
				'{day}' => $day,
				'{time}' => $time,
			]);
		}
		else if ($getFullDate)
		{
			return $this->getDateTimeOutput($date, $time);
		}
		else
		{
			return $date;
		}
	}

	/** @var array Relative date output cache  */
	protected $rdc = [];

	public function getRelativeDateTimeOutput($timestamp, $date, $time, $getFullDate = false)
	{
		if (!isset($this->rdc[$timestamp][$getFullDate]))
		{
			$this->rdc[$timestamp][$getFullDate] = $this->getRelativeDateTimeInternal($timestamp, $date, $time, $getFullDate);
		}

		return $this->rdc[$timestamp][$getFullDate];
	}

	public function getDayStartTimestamps()
	{
		if (!$this->dayStartTimestamps)
		{
			$date = new \DateTime('@' . \XF::$time);
			$date->setTimezone($this->getTimeZone());
			$date->setTime(0, 0, 0);

			[$todayStamp, $todayDow, $todayMonth, $todayYear] = explode('|', $date->format('U|w|n|Y'));

			$date->modify('+1 day');
			$tomorrowStamp = $date->format('U');

			$date->modify('-2 days');
			$yesterdayStamp = $date->format('U');

			$date->modify('-5 days');
			$weekStamp = $date->format('U');

			$month = new \DateTime("$todayYear-$todayMonth-01", $this->getTimeZone());
			$monthStamp = $month->format('U');

			$year = new \DateTime("$todayYear-01-01", $this->getTimeZone());
			$yearStamp = $year->format('U');

			$this->dayStartTimestamps = [
				'tomorrow'  => $tomorrowStamp,
				'now'       => \XF::$time,
				'today'     => $todayStamp,
				'todayDow'  => $todayDow,
				'yesterday' => $yesterdayStamp,
				'week'      => $weekStamp,
				'month'     => $monthStamp,
				'year'      => $yearStamp,
			];
		}

		return $this->dayStartTimestamps;
	}

	protected function _getDatePresets()
	{
		return [
			'1day' => ['-1 day', '1_day_ago'],
			'1week' => ['-1 week', '1_week_ago'],
			'2weeks' => ['-2 weeks', '2_weeks_ago'],
			'1month' => ['-1 month', '1_month_ago'],
			'3months' => ['-3 months', '3_months_ago'],
			'6months' => ['-6 months', '6_months_ago'],
			'9months' => ['-9 months', '9_months_ago'],
			'1year' => ['-1 year', '1_year_ago'],
			'2years' => ['-2 years', '2_years_ago'],
		];
	}

	/** @var array Short date output cache  */
	protected $sdc = [];

	public function getShortDate($timestamp)
	{
		if (!isset($this->sdc[$timestamp]))
		{
			$this->sdc[$timestamp] = $this->getShortDateInternal($timestamp);
		}

		return $this->sdc[$timestamp];
	}

	protected function getShortDateInternal($timeStamp = null)
	{
		if (is_null($timeStamp))
		{
			$timeStamp = \XF::$time;
		}

		$ts = new \DateTime('@' . $timeStamp);

		if ($timeStamp < $this->getDayStartTimestamps()['year'])
		{
			// full date, previous years
			return $this->formatDateTime($ts, $this->options['date_short_format']);
		}

		$now = new \DateTime('NOW');
		$interval = $now->diff($ts);

		if ($ts > $now || $interval->days > 30)
		{
			// full date for future dates or dates older than 29 days
			return $this->formatDateTime($ts, $this->options['date_short_recent_format']);
		}
		else if ($interval->days > 0)
		{
			// 5d
			return strtr($this->getPhraseCacheRaw('short_date_x_days'), [
				'{days}' => $interval->format('%d'),
			]);
		}

		if ($interval->h)
		{
			// 12h
			return strtr($this->getPhraseCacheRaw('short_date_x_hours'), [
				'{hours}' => $interval->format('%h'),
			]);
		}
		else
		{
			// 12h
			return strtr($this->getPhraseCacheRaw('short_date_x_minutes'), [
				'{minutes}' => $interval->format('%i'),
			]);
		}
	}

	public function getDatePresets($timeStamp = null)
	{
		if (is_null($timeStamp))
		{
			$timeStamp = \XF::$time;
		}

		$presets = [];

		foreach ($this->_getDatePresets() AS $period => $presetData)
		{
			$date = new \DateTime('@' . $timeStamp);
			$date->modify($presetData[0]);
			$presets[$date->format('Y-m-d')] = \XF::phrase($presetData[1]);
		}

		return $presets;
	}

	/**
	 * Formats the given number for a language/locale. Also used for file size formatting.
	 *
	 * @param mixed $number Number to format
	 * @param integer|string $precision Number of places to show after decimal point or word "size" for file size. Use -1 to get unchanged decimal precision
	 *
	 * @return string Formatted number
	 */
	public function numberFormat($number, $precision = 0)
	{
		if ($precision == -1)
		{
			$decimals = strpos(strrev($number), '.');
			if ($decimals !== false)
			{
				$precision = $decimals;
			}
			else
			{
				$precision = 0;
			}
		}

		return number_format(
			floatval($number),
			$precision,
			$this->options['decimal_point'],
			$this->options['thousands_separator']
		);
	}

	public function shortNumberFormat($number, $precision = 0)
	{
		$originalNumber = $number;
		$decimalSep = $this->options['decimal_point'];
		$thousandsSep = $this->options['thousands_separator'];

		if ($number >= 1000)
		{
			// Round number to the nearest 1000 with relevant precision
			$basePrecision = -3;
			$number = round($number, $basePrecision + $precision);
		}

		$phrase = null;

		if ($number >= 1000000000) // 1B
		{
			$number = number_format($number / 1000000000, $precision, $decimalSep, $thousandsSep);
			$phrase = 'x_b';
		}
		else if ($number >= 1000000) // 1M - 999M
		{
			$number = number_format($number / 1000000, $precision, $decimalSep, $thousandsSep);
			$phrase = 'x_m';
		}
		else if ($number >= 1000) // 1K - 999K
		{
			$number = number_format($number / 1000, $precision, $decimalSep, $thousandsSep);
			$phrase = 'x_k';
		}

		// This is the original full-form formatted number
		// we can return this if the number is unphrased anyway and also
		// pass it into the phrase to allow user to opt out of the short format.
		$default = $this->numberFormat($originalNumber, $precision);

		// return $number, not $number.0 when the decimal is 0.
		if (substr($number, -2) === "{$decimalSep}0")
		{
			$number = substr($number, 0, -2);
		}
		if (substr($default, -2) === "{$decimalSep}0")
		{
			$default = substr($default, 0, -2);
		}

		if ($phrase)
		{
			return str_replace(['{number}', '{default}'], [$number, $default], $this->getPhraseCacheRaw($phrase));
		}
		else
		{
			return $default;
		}
	}

	public function currencyFormat($value, $symbol, $desiredPrecision = 2, $format = null): string
	{
		if ($value === null)
		{
			$value = 0;
		}

		$v = floatval($value);
		if ($desiredPrecision == 0 && floor($v) !== $v)
		{
			// don't hide a decimal if we have one
			$desiredPrecision = 2;
		}

		$format = $format ?: $this->options['currency_format'];
		return strtr($format, [
			'{symbol}' => $symbol,
			'{value}' => $this->numberFormat($value, $desiredPrecision),
		]);
	}

	public function fileSizeFormat($number)
	{
		$decimalSep = $this->options['decimal_point'];
		$thousandsSep = $this->options['thousands_separator'];

		if ($number >= 1099511627776) // 1 TB
		{
			$number = number_format($number / 1099511627776, 1, $decimalSep, $thousandsSep);
			$phrase = 'x_tb';
		}
		else if ($number >= 1073741824) // 1 GB
		{
			$number = number_format($number / 1073741824, 1, $decimalSep, $thousandsSep);
			$phrase = 'x_gb';
		}
		else if ($number >= 1048576) // 1 MB
		{
			$number = number_format($number / 1048576, 1, $decimalSep, $thousandsSep);
			$phrase = 'x_mb';
		}
		else if ($number >= 1024) // 1 KB
		{
			$number = number_format($number / 1024, 1, $decimalSep, $thousandsSep);
			$phrase = 'x_kb';
		}
		else
		{
			$number = number_format($number, 0, $decimalSep, $thousandsSep);
			$phrase = 'x_bytes';
		}

		// return $number, not $number.0 when the decimal is 0.
		if (substr($number, -2) === "{$decimalSep}0")
		{
			$number = substr($number, 0, -2);
		}

		return str_replace('{size}', $number, $this->getPhraseCacheRaw($phrase));
	}

	public function __clone()
	{
		$this->date = clone $this->date;
	}
}
