<?php

namespace XF\Str;

use XF\Language;
use XF\Template\Templater;
use XF\Util\Php;
use XF\Util\Str;
use XF\Validator\Url;

use function chr, count, intval, is_int, is_null, is_string, strlen, strval;

class Formatter
{
	protected $censorRules = [];
	protected $censorChar = '*';
	protected $censorCache = null;

	protected $smilieTranslate = [];
	protected $smilieReverse = [];

	/**
	 * @var callable|null
	 */
	protected $smilieHtmlPather = null;

	/**
	 * @var callable|null
	 */
	protected $proxyHandler;

	protected $htmlPlaceholderId = 0;

	/**
	 * @var EmojiFormatter|null
	 */
	protected $emojiFormatter;

	public function censorText($string, $censorChar = null)
	{
		if ($string === null)
		{
			return '';
		}

		if ($censorChar !== null)
		{
			$map = $this->buildCensorMap($this->censorRules, $censorChar);
		}
		else
		{
			if ($this->censorCache === null)
			{
				$this->censorCache = $this->buildCensorMap($this->censorRules, $this->censorChar);
			}
			$map = $this->censorCache;
		}

		if ($map)
		{
			$string = preg_replace(
				array_keys($map),
				$map,
				$string
			);
		}

		return $string;
	}

	public function setCensorRules(array $censorRules, $censorChar)
	{
		$this->censorRules = $censorRules;
		$this->censorChar = $censorChar;
	}

	protected function buildCensorMap(array $censor, $censorCharacter)
	{
		$map = [];

		foreach ($censor AS $key => $word)
		{
			if (is_string($key) || !isset($word['regex']) || !isset($word['replace']))
			{
				// old format or broken
				continue;
			}

			$regex = $word['regex'];
			$replace = $word['replace'];

			$map[$regex] = is_int($replace) ? str_repeat($censorCharacter, $replace) : $replace;
		}

		return $map;
	}

	public function replacePhrasePlaceholders($string, ?Language $language = null)
	{
		if (!preg_match_all(
			'#\{phrase:(\w+)\}#iU',
			$string,
			$phraseMatches,
			PREG_SET_ORDER
		))
		{
			return $string;
		}

		if (!$language)
		{
			$language = \XF::language();
		}

		$replacements = [];
		foreach ($phraseMatches AS $phraseMatch)
		{
			$replacements[$phraseMatch[0]] = $language->phrase($phraseMatch[1]);
		}

		return strtr($string, $replacements);
	}

	public function replacePhraseSyntax($value, ?Language $language = null)
	{
		if (!preg_match_all(
			'#\{\{\s*phrase\(("|\')([a-z0-9_.():,]+)\\1(,\s*\{([^}]+)\})?\s*\)\s*\}\}#iU',
			$value,
			$phraseMatches,
			PREG_SET_ORDER
		))
		{
			return $value;
		}

		if (!$language)
		{
			$language = \XF::language();
		}

		$replacements = [];
		foreach ($phraseMatches AS $phraseMatch)
		{
			$phraseParams = [];
			if (!empty($phraseMatch[4]))
			{
				preg_match_all(
					'#("|\')(\w+)\\1\s*:\s*("|\')(.*)\\3#siU',
					$phraseMatch[4],
					$paramMatches,
					PREG_SET_ORDER
				);
				foreach ($paramMatches AS $paramMatch)
				{
					$phraseParams[$paramMatch[2]] = $paramMatch[4];
				}
			}

			$replacements[$phraseMatch[0]] = $language->phrase($phraseMatch[2], $phraseParams);
		}

		if (count($replacements) == 1 && key($replacements) == $value)
		{
			return current($replacements);
		}

		return $replacements ? strtr($value, $replacements) : $value;
	}

	public function addSmilies(array $smilies)
	{
		foreach ($smilies AS $smilie)
		{
			foreach ($smilie['smilieText'] AS $textOffset => $text)
			{
				$this->smilieTranslate[$text] = "\0" . $smilie['smilie_id'] . ':' . intval($textOffset) . "\0";
			}

			$this->smilieReverse[$smilie['smilie_id']] = $smilie;
		}
	}

	public function getSmilieStrings()
	{
		return array_keys($this->smilieTranslate);
	}

	public function setSmilieHtmlPather(?callable $pather = null)
	{
		$this->smilieHtmlPather = $pather;
	}

	public function replaceSmiliesInText($text, $replaceCallback, $escapeCallback = null)
	{
		if ($this->smilieTranslate)
		{
			$text = strtr($text, $this->smilieTranslate);
		}

		if ($escapeCallback)
		{
			/** @var callable $escapeCallback */
			$text = $escapeCallback($text);
		}

		if ($this->smilieTranslate)
		{
			$reverse = $this->smilieReverse;
			$text = preg_replace_callback('#\0(\d+)(?::(\d+))?\0#', function ($match) use ($reverse, $replaceCallback)
			{
				$id = $match[1];
				$textOffsetId = isset($match[2]) ? intval($match[2]) : null;
				return isset($reverse[$id]) ? $replaceCallback($id, $reverse[$id], $textOffsetId) : '';
			}, $text);
		}

		return $text;
	}

	protected $smilieFormatCache = [];

	/**
	 * Replaces the smilies with their display equivalents in an HTML scenario.
	 *
	 * @param string $text Non-HTML escaped
	 * @param string $format See getSmilieHtml for available options.
	 *
	 * @return string Text around smilies will be HTML escaped
	 */
	public function replaceSmiliesHtml(string $text, string $format = 'default'): string
	{
		if (!isset($this->smilieFormatCache[$format]))
		{
			$this->smilieFormatCache[$format] = [];
		}

		$cache = &$this->smilieFormatCache[$format];

		$replace = function ($id, $smilie, $textOffsetId) use (&$cache, $format)
		{
			$textOffsetId = $textOffsetId ?: 0;
			$cacheKey = "$id-$textOffsetId";

			if (!isset($cache[$cacheKey]))
			{
				$cache[$cacheKey] = $this->getSmilieHtml($id, $smilie, $textOffsetId, $format);
			}

			return $cache[$cacheKey];
		};

		return $this->replaceSmiliesInText($text, $replace, 'htmlspecialchars');
	}

	/**
	 * @param int    $id Smilie ID
	 * @param array  $smilie Smilie info
	 * @param int    $textOffset Array offset in the smilie's smilieText list that represents the user-entered value
	 * @param string $format Smilie display format. Options: default, rte, emoji-only
	 *
	 * @return string
	 * @throws \Exception
	 */
	public function getSmilieHtml(int $id, array $smilie, int $textOffset = 0, string $format = 'default'): string
	{
		$smilieTitle = htmlspecialchars($smilie['title']);

		$smilieText = $smilie['smilieText'][$textOffset] ?? null;
		if (!is_string($smilieText))
		{
			$smilieText = reset($smilie['smilieText']);
		}
		$smilieText = htmlspecialchars($smilieText);

		$pather = $this->smilieHtmlPather;

		if ($format === 'emoji-only')
		{
			if (!empty($smilie['emoji_shortname']))
			{
				return $this->getEmojiFormatter()->formatShortnameToEmoji($smilie['emoji_shortname']);
			}
			else
			{
				return $smilieText;
			}
		}

		if (empty($smilie['image_url']))
		{
			$shortname = $smilie['emoji_shortname'];
			$emojiInfo = $this->getEmojiFormatter()->getInfoFromShortname($shortname, [
				'forceImage' => $format === 'rte',
			]);

			if ($emojiInfo['image'])
			{
				$url = htmlspecialchars($emojiInfo['image']);
				$size = $emojiInfo['size'];

				// CSS controls the actual dimensions, but setting width/height should allow aspect ratio optimizations
				return '<img src="' . $url . '" class="smilie smilie--emoji" '
					. 'loading="lazy" width="' . $size . '" height="' . $size . '" alt="' . $smilieText . '" '
					. 'title="' . $smilieTitle . '    ' . $smilieText . '" '
					. ' data-smilie="' . $id . '"'
					. 'data-shortname="' . $smilieText . '" />';
			}
			else
			{
				return '<span class="smilie smilie--emoji"'
					. ' title="' . $smilieTitle . '    ' . $smilieText . '"'
					. ' data-smilie="' . $id . '"'
					. ' data-shortname="' . $smilieText . '">'
					. htmlspecialchars($emojiInfo['text']) . '</span>';
			}
		}
		else if (empty($smilie['sprite_params']))
		{
			$url = htmlspecialchars($pather ? $pather($smilie['image_url'], 'base') : $smilie['image_url']);
			$srcSet = '';
			if (!empty($smilie['image_url_2x']))
			{
				$url2x = htmlspecialchars($pather ? $pather($smilie['image_url_2x'], 'base') : $smilie['image_url_2x']);
				$srcSet = 'srcset="' . $url . ' 1x, ' . $url2x . ' 2x"';
			}

			return '<img src="' . $url . '" ' . $srcSet . ' class="smilie" loading="lazy" alt="' . $smilieText
				. '" title="' . $smilieTitle . '    ' . $smilieText . '" '
				. 'data-shortname="' . $smilieText . '" />';
		}
		else
		{
			// embed a data URI to avoid a request that doesn't respect paths fully
			return '<img src="' . Templater::TRANSPARENT_IMG_URI . '" class="smilie smilie--sprite smilie--sprite' . $id . '" alt="' . $smilieText
				. '" title="' . $smilieTitle . '    ' . $smilieText . '" loading="lazy" '
				. 'data-shortname="' . $smilieText . '" />';
		}
	}

	/**
	 * @deprecated Use getSmilieHtml instead.
	 *
	 * @param int $id
	 * @param array $smilie
	 *
	 * @return string
	 */
	public function getDefaultSmilieHtml($id, array $smilie)
	{
		return $this->getSmilieHtml($id, $smilie);
	}

	public function convertStructuredTextToHtml($string, $nl2br = true)
	{
		$string = $this->censorText($string);
		$string = \XF::escapeString($string);
		$string = $this->getEmojiFormatter()->formatEmojiToImage($string, ['wrapNative' => true]);
		$string = $this->autoLinkStructuredText($string);
		$string = $this->linkStructuredTextMentions($string);

		if ($nl2br)
		{
			$string = nl2br($string);
		}

		return $string;
	}

	public function moveHtmlToPlaceholders($string, &$restorerClosure)
	{
		$placeholders = [];

		$string = preg_replace_callback(
			'#<[^>]*>#si',
			function (array $match) use (&$placeholders)
			{
				$placeholder = "\x1A" . $this->htmlPlaceholderId . "\x1A";
				$placeholders[$placeholder] = $match[0];

				$this->htmlPlaceholderId++;

				return $placeholder;
			},
			$string
		);

		$restorerClosure = function ($string) use ($placeholders)
		{
			return strtr($string, $placeholders);
		};

		return $string;
	}

	public function removeHtmlPlaceholders($string)
	{
		return preg_replace("#\x1A\\d+\x1A#", '', $string);
	}

	public function moveEmojiToPlaceholders($string, &$restorerClosure)
	{
		$placeholders = [];

		$replaceCallback = function ($match) use (&$placeholders)
		{
			$placeholder = "\x1A" . $this->htmlPlaceholderId . "\x1A";

			$placeholders[$placeholder] = $match[0];

			$this->htmlPlaceholderId++;

			return $placeholder;
		};

		if (@preg_match('/\p{Emoji}/u', '😀'))
		{
			$string = preg_replace_callback(
				'/\p{Emoji}/u',
				$replaceCallback,
				$string
			);
		}
		else
		{
			$string = preg_replace_callback(
				'/[\x{1F600}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F700}-\x{1F77F}\x{1F780}-\x{1F7FF}\x{1F800}-\x{1F8FF}\x{1F900}-\x{1F9FF}\x{1FA00}-\x{1FA6F}\x{1FA70}-\x{1FAFF}\x{2300}-\x{23FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{2900}-\x{297F}\x{2B50}-\x{2BFF}\x{2C60}-\x{2C7F}\x{2E00}-\x{2E7F}]+/u',
				$replaceCallback,
				$string
			);
		}

		$restorerClosure = function ($string) use ($placeholders)
		{
			return strtr($string, $placeholders);
		};

		return $string;
	}

	public function moveHtmlEntitiesToPlaceholders($string, &$restorerClosure)
	{
		$placeholders = [];

		$string = preg_replace_callback(
			'/&(?:[a-z\d]+|#\d+|#x[a-f\d]+);/i',
			function ($match) use (&$placeholders)
			{
				$placeholder = "\x1A" . $this->htmlPlaceholderId . "\x1A";

				$placeholders[$placeholder] = $match[0];

				$this->htmlPlaceholderId++;

				return $placeholder;
			},
			$string
		);

		$restorerClosure = function ($string) use ($placeholders)
		{
			$string = preg_replace("/(\x1A)<[^>]*>(\d+)<[^>]*>(\x1A)/", "$1$2$3", $string);
			$string = strtr($string, $placeholders);

			// sanity check in case the manipulation has left a placeholder
			$string = preg_replace("/\x1A.*\x1A/U", '', $string);

			return $string;
		};

		return $string;
	}

	public function autoLinkStructuredText($string)
	{
		$string = $this->moveHtmlToPlaceholders($string, $restorePlaceholders);

		$string = preg_replace_callback(
			'#(?<=[^a-z0-9@-]|^)(https?://|www\.)[^\s"<>{}`\x1A]+#i',
			function (array $match)
			{
				$url = $this->removeHtmlPlaceholders($match[0]);
				$url = htmlspecialchars_decode($url, ENT_QUOTES);
				$link = $this->prepareAutoLinkedUrl($url);

				if (!$link['url'])
				{
					return htmlspecialchars($url, ENT_QUOTES, 'utf-8');
				}

				$linkInfo = $this->getLinkClassTarget($link['url']);
				$classAttr = $linkInfo['class'] ? " class=\"$linkInfo[class]\"" : '';
				$targetAttr = $linkInfo['target'] ? " target=\"$linkInfo[target]\"" : '';
				$noFollowAttr = $linkInfo['trusted'] ? '' : ' rel="nofollow"';

				return '<a href="' . htmlspecialchars($link['url'], ENT_QUOTES, 'utf-8')
					. "\"{$classAttr}{$noFollowAttr}{$targetAttr}>"
					. htmlspecialchars($link['linkText'], ENT_QUOTES, 'utf-8') . '</a>'
					. htmlspecialchars($link['suffixText'], ENT_QUOTES, 'utf-8');
			},
			$string
		);

		$string = $restorePlaceholders($string);

		return $string;
	}

	public function convertStructuredTextLinkToBbCode($string)
	{
		$string = preg_replace_callback(
			'#(?<=[^a-z0-9@/\.-]|^)(?<!\]\(|url=(?:"|\')|url\]|url\sunfurl=(?:"|\')true(?:"|\')\]|img\])(https?://|www\.)[^\s"<>{}`]+#iu',
			function (array $match)
			{
				$link = $this->prepareAutoLinkedUrl($match[0]);

				if ($link['url'] !== $link['linkText'])
				{
					return '[URL="' . $link['linkText'] . '"]' . $link['linkText'] . '[/URL]' . $link['suffixText'];
				}

				return '[URL]' . $link['url'] . '[/URL]' . $link['suffixText'];
			},
			$string
		);

		return $string;
	}

	public function getLinkClassTarget($url)
	{
		$target = '_blank';
		$class = 'link link--external';
		$type = 'external';
		$schemeMatch = true;

		$urlInfo = @parse_url($url);
		if ($urlInfo)
		{
			if (empty($urlInfo['host']))
			{
				$isInternal = true;
			}
			else
			{
				$request = \XF::app()->request();
				$host = $urlInfo['host'] . (!empty($urlInfo['port']) ? ":$urlInfo[port]" : '');
				$isInternal = ($host == $request->getHost());

				$scheme = (!empty($urlInfo['scheme']) ? strtolower($urlInfo['scheme']) : 'http');
				$schemeMatch = $scheme == ($request->isSecure() ? 'https' : 'http');
			}

			if ($isInternal)
			{
				$target = '';
				$class = 'link link--internal';
				$type = 'internal';
			}
		}

		return [
			'class' => $class,
			'target' => $target,
			'type' => $type,
			'trusted' => $type == 'internal',
			'local' => $type == 'internal' && $schemeMatch,
		];
	}

	public function prepareAutoLinkedUrl($url, array $options = [])
	{
		$options = array_replace([
			'processTrailers' => true,
		], $options);

		$suffixText = '';

		if (preg_match('/&(?:quot|gt|lt);/i', $url, $match, PREG_OFFSET_CAPTURE))
		{
			$suffixText = substr($url, $match[0][1]);
			$url = substr($url, 0, $match[0][1]);
		}

		$linkText = $url;

		if (strpos($url, '://') === false)
		{
			$url = 'http://' . $url;
		}

		if ($options['processTrailers'])
		{
			do
			{
				$matchedTrailer = false;
				$lastChar = substr($url, -1);
				switch ($lastChar)
				{
					case ')':
					case ']':
						$closer = $lastChar;
						$opener = $lastChar == ']' ? '[' : '(';

						if (substr_count($url, $closer) == substr_count($url, $opener))
						{
							break;
						}
						// no break

					case '(':
					case '[':
					case '.':
					case ',':
					case '!':
					case ':':
					case "'":
						$suffixText = $lastChar . $suffixText;
						$url = substr($url, 0, -1);
						$linkText = substr($linkText, 0, -1);

						$matchedTrailer = true;
						break;
				}
			}
			while ($matchedTrailer);
		}

		if (preg_match('/proxy\.php\?\w+=(http[^&]+)&/i', $url, $match))
		{
			// proxy link of some sort, adjust to the original one
			$proxiedUrl = urldecode($match[1]);
			if (preg_match('/./su', $proxiedUrl))
			{
				if ($proxiedUrl == $linkText)
				{
					$linkText = $proxiedUrl;
				}
				$url = $proxiedUrl;
			}
		}

		$validator = \XF::app()->validator(Url::class);
		$validator->setOption('strict', false);
		$url = $validator->coerceValue($url);
		if (!$validator->isValid($url))
		{
			$url = null;
		}

		return [
			'url' => $url,
			'linkText' => $linkText,
			'suffixText' => $suffixText,
		];
	}

	public function linkStructuredTextMentions($string)
	{
		$string = $this->moveHtmlToPlaceholders($string, $restorePlaceholders);

		$string = preg_replace_callback(
			MentionFormatter::STRUCTURED_MENTION_REGEX,
			function (array $match)
			{
				$userId = intval($match[1]);
				$username = $this->removeHtmlPlaceholders($match[3]);
				$username = htmlspecialchars($username, ENT_QUOTES, 'utf-8', false);

				$link = \XF::app()->router('public')->buildLink('full:members', ['user_id' => $userId]);

				return sprintf(
					'<a href="%s" class="username" data-user-id="%d" data-username="%s" data-xf-init="member-tooltip">%s</a>',
					htmlspecialchars($link),
					$userId,
					$username,
					$username
				);
			},
			$string
		);

		$string = $restorePlaceholders($string);

		return $string;
	}

	public function convertStructuredTextMentionsToBbCode($string)
	{
		$string = preg_replace_callback(
			MentionFormatter::STRUCTURED_MENTION_REGEX,
			function (array $match)
			{
				$userId = intval($match[1]);
				$username = htmlspecialchars($match[3], ENT_QUOTES, 'utf-8', false);

				return '[USER=' . $userId . ']' . $username . '[/USER]';
			},
			$string
		);

		return $string;
	}

	public function getProxiedUrlIfActive($type, $url)
	{
		return $this->getProxiedUrlIfActiveExtended($type, $url);
	}

	public function getProxiedUrlIfActiveExtended($type, $url, array $options = [])
	{
		if (!$this->proxyHandler)
		{
			return null;
		}

		$handler = $this->proxyHandler;
		return $handler($type, $url, $options);
	}

	public function setProxyHandler(?callable $handler = null)
	{
		$this->proxyHandler = $handler;
	}

	public function getProxyHandler()
	{
		return $this->proxyHandler;
	}

	public function splitLongWords($string, $breakLength, $inserter = null)
	{
		$breakLength = intval($breakLength);
		if ($breakLength < 1 || $breakLength > strlen($string)) // strlen isn't completely accurate, but this is an optimization
		{
			return $string;
		}

		if ($inserter === null)
		{
			$inserter = chr(0xE2) . chr(0x80) . chr(0x8B); // UTF-8 for zero width space
		}

		return preg_replace('#[^\s]{' . $breakLength . '}(?=[^\s])#u', '$0' . $inserter, $string);
	}

	public function wholeWordTrim($string, $maxLength, $offset = 0, $ellipsis = '...')
	{
		if (is_null($string) || $string === '')
		{
			return '';
		}

		$ellipsisLen = strlen($ellipsis);

		if ($offset)
		{
			$string = preg_replace('/^\S*\s+/s', '', Str::substr($string, $offset));
			if ($maxLength > 0)
			{
				$maxLength = max(1, $maxLength - $ellipsisLen);
			}
		}

		$strLength = Str::strlen($string);
		if ($maxLength > 0 && $strLength > $maxLength)
		{
			$maxLength -= $ellipsisLen;

			if ($maxLength > 0)
			{
				$string = Str::substr($string, 0, $maxLength);
				$string = strrev(preg_replace('/^\S*\s+/s', '', strrev($string)));
				$string = rtrim($string, ',.!?:;') . $ellipsis;
			}
			else if ($maxLength <= 0)
			{
				// too short with the ellipsis, can't really display anything
				$string = $ellipsis;
				$offset = 0;
			}
		}

		if ($offset)
		{
			$string = $ellipsis . $string;
		}

		return $string;
	}

	/**
	 * Trims a string to a whole word, attempting to leave BB code markup but minimize its impact.
	 * This will replace BB code markup with a token that is much shorter (and not breakable within it)
	 * and trimming will be done on the resulting string. Afterwards, the token will be replaced with the
	 * original BB code markup value.
	 *
	 * @param string $string
	 * @param int    $maxLength
	 * @param int    $offset
	 * @param string $ellipsis
	 *
	 * @return string
	 */
	public function wholeWordTrimBbCode(
		string $string,
		int $maxLength,
		int $offset = 0,
		string $ellipsis = '...'
	): string
	{
		$tokens = [];

		$string = preg_replace_callback(
			'#(\[\w+(?:=[^\]]*)?+\]|\[\w+(?:\s?\w+="[^"]*")+\]|\[/\w+\])#si',
			function ($match) use (&$tokens)
			{
				$tokenId = count($tokens);
				$token = "\x1A" . $tokenId . "\x1A";

				$tokens[$tokenId] = $match[0];

				return $token;
			},
			$string
		);

		$string = $this->wholeWordTrim($string, $maxLength, $offset, $ellipsis);

		$string = preg_replace_callback(
			"#\x1A(\d+)\x1A#",
			function ($match) use ($tokens)
			{
				return $tokens[$match[1]] ?? '';
			},
			$string
		);

		$string = str_replace("\x1A", '', $string);

		return $string;
	}

	public function wholeWordTrimAroundTerm($string, $maxLength, $term, $ellipsis = '...')
	{
		$stringLength = Str::strlen($string);

		if ($stringLength > $maxLength)
		{
			$term = strval($term);

			if ($term !== '')
			{
				// TODO: slightly more intelligent search term matching, breaking up multiple words etc.
				$termPosition = Str::strpos(Str::strtolower($string), Str::strtolower($term));
			}
			else
			{
				$termPosition = false;
			}

			if ($termPosition !== false)
			{
				$startPos = $termPosition + Str::strlen($term); // add term length to term start position
				$startPos -= intval($maxLength / 2); // count back half the max characters
				$startPos = max(0, $startPos); // don't overflow the beginning
				$startPos = min($startPos, $stringLength - $maxLength); // don't overflow the end
			}
			else
			{
				$startPos = 0;
			}

			$string = $this->wholeWordTrim($string, $maxLength, $startPos, $ellipsis);
		}

		return $string;
	}

	public function highlightTermForHtml($string, $term, $class = 'textHighlight')
	{
		$string = $this->moveHtmlEntitiesToPlaceholders(
			htmlspecialchars($string),
			$restorePlaceholders
		);

		$term = trim(preg_replace('#((^|\s)[+|-]|[/()"~^])#', ' ', strval($term)));
		if ($term !== '')
		{
			$string = preg_replace(
				'/(?<!\\x1A|\w)(' . preg_replace('#\s+#', '|', preg_quote(htmlspecialchars($term), '/')) . ')/siu',
				'<em class="' . htmlspecialchars($class) . '">\1</em>',
				\XF::escapeString($string)
			);
		}
		else
		{
			$string = \XF::escapeString($string);
		}

		$string = $restorePlaceholders($string);

		return $string;
	}

	public function stripBbCode($string, array $options = [])
	{
		if ($string === null)
		{
			return '';
		}

		$options = array_merge([
			'stripQuote' => false,
			'hideUnviewable' => true,
		], $options);

		if ($options['stripQuote'])
		{
			$parts = preg_split('#(\[quote[^\]]*\]|\[/quote\])#i', $string, -1, PREG_SPLIT_DELIM_CAPTURE);
			$string = '';
			$quoteLevel = 0;
			foreach ($parts AS $i => $part)
			{
				if ($i % 2 == 0)
				{
					// always text, only include if not inside quotes
					if ($quoteLevel == 0)
					{
						$string .= rtrim($part) . "\n";
					}
				}
				else
				{
					// quote start/end
					if ($part[1] == '/')
					{
						// close tag, down a level if open
						if ($quoteLevel)
						{
							$quoteLevel--;
						}
					}
					else
					{
						// up a level
						$quoteLevel++;
					}
				}
			}
		}

		// replaces unviewable tags with a text representation
		$string = str_replace('[*]', '', $string);
		$string = preg_replace(
			'#\[(attach|media|img|spoiler|ispoiler)[^\]]*\].*\[/\\1\]#siU',
			$options['hideUnviewable'] ? '' : '[\\1]',
			$string
		);

		// split the string into possible delimiters and text; even keys (from 0) are strings, odd are delimiters
		$parts = preg_split('#(\[\w+(?:=[^\]]*)?+\]|\[\w+(?:\s?\w+="[^"]*")+\]|\[/\w+\])#si', $string, -1, PREG_SPLIT_DELIM_CAPTURE);
		$total = count($parts);
		if ($total < 2)
		{
			return trim($string);
		}

		$closes = [];
		$skips = [];
		$newString = '';

		// first pass: find all the closing tags and note their keys
		for ($i = 1; $i < $total; $i += 2)
		{
			if (preg_match("#^\\[/(\w+)]#i", $parts[$i], $match))
			{
				$closes[strtolower($match[1])][$i] = $i;
			}
		}

		// second pass: look for all the text elements and any opens, then find
		// the first corresponding close that comes after it and remove it.
		// if we find that, don't display the open or that close
		for ($i = 0; $i < $total; $i++)
		{
			$part = $parts[$i];
			if ($i % 2 == 0)
			{
				// string part
				$newString .= $part;
				continue;
			}

			if (!empty($skips[$i]))
			{
				// known close
				continue;
			}

			if (preg_match('/^\[(\w+)(?:=|\s?\w+="[^"]*"|\])/i', $part, $match))
			{
				$tagName = strtolower($match[1]);
				if (!empty($closes[$tagName]))
				{
					do
					{
						$closeKey = reset($closes[$tagName]);
						if ($closeKey)
						{
							unset($closes[$tagName][$closeKey]);
						}
					}
					while ($closeKey && $closeKey < $i);
					if ($closeKey)
					{
						// found a matching close after this tag
						$skips[$closeKey] = true;
						continue;
					}
				}
			}

			$newString .= $part;
		}

		return trim($newString);
	}

	public function stripStructuredText($string)
	{
		return preg_replace(
			MentionFormatter::STRUCTURED_MENTION_REGEX,
			'\\3',
			$string
		);
	}

	public function getBbCodeForQuote($bbCode, $context)
	{
		$bbCodeContainer = \XF::app()->bbCode();

		$processor = $bbCodeContainer->processor()
			->addProcessorAction('quotes', $bbCodeContainer->processorAction('quotes'))
			->addProcessorAction('censor', $bbCodeContainer->processorAction('censor'));

		return trim($processor->render($bbCode, $bbCodeContainer->parser(), $bbCodeContainer->rules($context)));
	}

	public function getBbCodeFromSelectionHtml($html)
	{
		$html = preg_replace_callback('/<div.*?data-embed-content="([\w-]+)" data-embed-content-url="(.*)".*?<\/div>/siU', function (array $matches)
		{
			return "[EMBED content=\"$matches[1]\"]$matches[2][/EMBED]";
		}, $html);

		// attempt to parse the selected HTML into BB code
		$tags = '<' . implode('><', $this->getSelectionAllowedTags()) . '>';
		$html = trim(strip_tags($html, $tags));

		// handle CODE output and turn it back into BB code
		$html = preg_replace_callback('/<code data-language="(\w+)">(.*)<\/code>/siU', function (array $matches)
		{
			return "[CODE=$matches[1]]" . str_replace("\n", '<br>', trim($matches[2])) . "[/CODE]";
		}, $html);

		// handle ICODE output to BB code
		$html = preg_replace_callback('/<code class="bbCodeInline">(.*)<\/code>/siU', function (array $matches)
		{
			return "[ICODE]" . trim($matches[1]) . "[/ICODE]";
		}, $html);

		return $html;
	}

	protected function getSelectionAllowedTags()
	{
		return [
			'a',
			'b',
			'br',
			'code',
			'h1',
			'h2',
			'h3',
			'h4',
			'h5',
			'h6',
			'hr',
			'i',
			'img',
			'li',
			'ol',
			'pre',
			'span',
			'table',
			'tbody',
			'td',
			'tfoot',
			'th',
			'thead',
			'tr',
			'u',
			'ul',
		];
	}

	public function snippetString($string, $maxLength = 0, array $options = [])
	{
		if (is_null($string) || $string === '')
		{
			return '';
		}

		$options = array_merge([
			'term' => '',
			'fromStart' => false,
			'stripBbCode' => false,
			'stripQuote' => false,
			'hideUnviewable' => true,
			'stripHtml' => false,
			'stripPlainTag' => false,
			'censor' => true,
		], $options);

		if ($options['stripQuote'])
		{
			$options['stripBbCode'] = true;
			$options['stripPlainTag'] = true;
		}

		if ($options['stripHtml'])
		{
			$string = strip_tags($string);
		}
		if ($options['stripBbCode'])
		{
			$string = $this->stripBbCode($string, ['stripQuote' => $options['stripQuote'], 'hideUnviewable' => $options['hideUnviewable']]);
		}
		if ($options['stripPlainTag'])
		{
			$string = $this->stripStructuredText($string);
		}

		if ($maxLength)
		{
			if ($options['fromStart'] || !$options['term'])
			{
				$string = $this->wholeWordTrim($string, $maxLength);
			}
			else
			{
				$string = $this->wholeWordTrimAroundTerm($string, $maxLength, $options['term']);
			}
		}

		$string = trim($string);

		if ($options['censor'])
		{
			$string = $this->censorText($string);
		}

		return $string;
	}

	public function createKeyValueSetFromString($string)
	{
		$values = [];

		preg_match_all('/
			^\s*
			(?P<name>([^=\r\n])*?)
			\s*=\s*
			(?P<value>.*?)
			\s*$
		/mix', trim($string), $matches, PREG_SET_ORDER);

		foreach ($matches AS $match)
		{
			$value = $this->replacePhraseSyntax($match['value']);
			$values[$match['name']] = $value;
		}

		return $values;
	}

	public function camelCase($string, $glue = '_')
	{
		return Php::camelCase($string, $glue);
	}

	public function fromCamelCase($string, $glue = '_')
	{
		return Php::fromCamelCase($string, $glue);
	}

	/**
	 * @return MentionFormatter
	 *
	 * @throws \Exception
	 */
	public function getMentionFormatter()
	{
		$class = \XF::extendClass(MentionFormatter::class);
		return new $class();
	}

	/**
	 * @param null $forceStyle
	 * @param bool $forceCdn
	 *
	 * @return EmojiFormatter
	 * @throws \Exception
	 */
	public function getEmojiFormatter($forceStyle = null, $forceCdn = false)
	{
		$canCache = ($forceStyle === null && $forceCdn === false);

		if (!$this->emojiFormatter || !$canCache)
		{
			$options = \XF::options();
			$pather = \XF::app()->container('request.pather');

			$config = [
				'style' => $forceStyle ?: $options->emojiStyle,
				'source' => $forceCdn ? 'cdn' : $options->emojiSource['source'],
				'path' => $pather(trim($options->emojiSource['path'], '/') . '/', 'base'),
				'uc_filename' => null,
			];

			$class = \XF::extendClass(EmojiFormatter::class);
			$formatter = new $class($config);

			if (!$canCache)
			{
				return $formatter;
			}

			$this->emojiFormatter = $formatter;
		}

		return $this->emojiFormatter;
	}
}
