<?php

namespace XF\BbCode\Renderer;

use XF\App;
use XF\Data\CodeLanguage;
use XF\Entity\Attachment;
use XF\Entity\EmbedResolverTrait;
use XF\Entity\User;
use XF\Mvc\Entity\Entity;
use XF\PreEscaped;
use XF\Repository\UnfurlRepository;
use XF\Str\Formatter;
use XF\Template\Templater;
use XF\Util\Arr;
use XF\Util\Str;
use XF\Util\Url;

use function call_user_func, count, in_array, intval, is_array, is_int, is_string, strlen, strval;

class Html extends AbstractRenderer
{
	protected $trimAfter = 0;

	protected $mediaSites = [];

	protected $legacyMediaSites = [
		'liveleak',
		'metacafe',
	];

	protected $allowedUrlProtocolRegex = '#^(https?|ftp)://#i';

	protected $allowedCodeLanguages;

	// anchor => number of occurrences
	protected $anchorOccurrences = [];

	/**
	 * @var Formatter
	 */
	protected $formatter;

	/**
	 * @var Templater
	 */
	protected $templater;

	public function __construct(Formatter $formatter, Templater $templater)
	{
		$this->formatter = $formatter;
		$this->templater = $templater;
		$this->addDefaultTags();
	}

	public function getTemplater()
	{
		return $this->templater;
	}

	public function setTemplater(Templater $templater)
	{
		$this->templater = $templater;
	}

	public function addDefaultTags()
	{
		$this->addTag('b', ['replace' => ['<b>', '</b>']]);
		$this->addTag('i', ['replace' => ['<i>', '</i>']]);
		$this->addTag('u', ['replace' => ['<u>', '</u>']]);
		$this->addTag('s', ['replace' => ['<s>', '</s>']]);
		$this->addTag('color', ['callback' => 'renderTagColor']);

		$this->addTag('font', ['callback' => 'renderTagFont']);
		$this->addTag('size', ['callback' => 'renderTagSize']);

		$this->addTag('url', [
			'callback' => 'renderTagUrl',
			'trimAfter' => function ($option, array $tag)
			{
				if (is_array($option) && isset($option['unfurl']) && $option['unfurl'] === 'true')
				{
					return 1;
				}
				return null;
			},
		]);
		$this->addTag('email', ['callback' => 'renderTagEmail']);

		$this->addTag('left', [
			'callback' => 'renderTagAlign',
			'trimAfter' => 1,
		]);
		$this->addTag('center', [
			'callback' => 'renderTagAlign',
			'trimAfter' => 1,
		]);
		$this->addTag('right', [
			'callback' => 'renderTagAlign',
			'trimAfter' => 1,
		]);
		$this->addTag('justify', [
			'callback' => 'renderTagAlign',
			'trimAfter' => 1,
		]);

		$this->addTag('indent', [
			'callback' => 'renderTagIndent',
			'trimAfter' => 1,
		]);

		$this->addTag('heading', [
			'callback' => 'renderTagHeading',
			'trimAfter' => 1,
		]);

		$this->addTag('hr', [
			'callback' => 'renderTagHr',
			'trimAfter' => 1,
		]);

		$this->addTag('img', [
			'callback' => 'renderTagImage',
		]);

		$this->addTag('quote', [
			'callback' => 'renderTagQuote',
			'trimAfter' => 1,
		]);

		$this->addTag('code', [
			'callback' => 'renderTagCode',
			'stopBreakConversion' => true,
			'trimAfter' => 1,
		]);

		$this->addTag('icode', [
			'callback' => 'renderTagInlineCode',
			'stopBreakConversion' => true,
		]);

		$this->addTag('php', [
			'callback' => 'renderTagPhp',
			'stopBreakConversion' => true,
			'trimAfter' => 1,
		]);

		$this->addTag('html', [
			'callback' => 'renderTagHtml',
			'stopBreakConversion' => true,
			'trimAfter' => 1,
		]);

		$this->addTag('list', [
			'callback' => 'renderTagList',
			'trimAfter' => 1,
		]);

		$this->addTag('plain', [
			'replace' => ['', ''],
		]);

		$this->addTag('media', [
			'callback' => 'renderTagMedia',
			'trimAfter' => 1,
		]);

		$this->addTag('spoiler', [
			'callback' => 'renderTagSpoiler',
			'trimAfter' => 1,
		]);

		$this->addTag('ispoiler', [
			'callback' => 'renderTagInlineSpoiler',
		]);

		$this->addTag('attach', [
			'callback' => 'renderTagAttach',
		]);

		$this->addTag('user', [
			'callback' => 'renderTagUser',
		]);

		$this->addTag('table', [
			'callback' => 'renderTagTable',
			'trimAfter' => 1,
		]);

		$this->addTag('embed', [
			'callback' => 'renderTagEmbed',
			'trimAfter' => 1,
		]);
	}

	public function getCustomTagConfig(array $tag)
	{
		$output = [];

		if ($tag['bb_code_mode'] == 'replace')
		{
			$output['replace'] = $tag['replace_html'];
		}
		else if ($tag['bb_code_mode'] == 'callback')
		{
			$output['callback'] = [$tag['callback_class'], $tag['callback_method']];
		}

		if ($tag['trim_lines_after'])
		{
			$output['trimAfter'] = $tag['trim_lines_after'];
		}

		if ($tag['disable_nl2br'])
		{
			$output['stopBreakConversion'] = true;
		}

		if ($tag['allow_empty'])
		{
			$output['keepEmpty'] = true;
		}

		return $output;
	}

	public function addMediaSite($siteId, array $config)
	{
		$this->mediaSites[$siteId] = $config;
	}

	public function addMediaSites(array $sites)
	{
		foreach ($sites AS $siteId => $site)
		{
			$this->addMediaSite($siteId, $site);
		}
	}

	public function getDefaultOptions()
	{
		return [
			'stopSmilies' => 0,
			'smilieFormat' => 'default',
			'replaceEmojis' => true,
			'stopBreakConversion' => 0,
			'shortenUrl' => true,
			'noFollowUrl' => true,
			'noProxy' => false,
			'attachments' => [],
			'viewAttachments' => false,
			'user' => null,
			'entity' => null,
			'lightbox' => true,
			'treatAsStructuredText' => false,
			'plain' => false,
			'allowUnfurl' => true,
			'simpleUnfurl' => false,
			'showHeadingAnchors' => true,
			'withinEmbedResolver' => false,
		];
	}

	protected function setupRenderOptions(array $ast, array &$options)
	{
		if (
			!empty($options['user'])
			&& $options['user'] instanceof User
			&& $options['user']->isLinkTrusted()
		)
		{
			$options['noFollowUrl'] = false;
		}

		if (!empty($options['treatAsStructuredText']))
		{
			if (count($ast) > 1 || !$ast || is_array($ast[0]))
			{
				// contains BB code, not legacy
				$options['treatAsStructuredText'] = false;
			}
			else if (
				preg_match('#(?<=[^a-z0-9@-]|^)(https?://|www\.)[^\s"<>{}`]#i', $ast[0])
				|| preg_match('#(?<=^|\s|[\](,/\'"]|--|@)@\[\d+:#i', $ast[0])
			)
			{
				// a link that hasn't been converted to BB code or a structured text mention means legacy
			}
			else
			{
				// nothing to indicate it as legacy, so assume not
				$options['treatAsStructuredText'] = false;
			}
		}
	}

	protected function setupRender(array $ast, array $options)
	{
		$this->trimAfter = 0;
		$this->anchorOccurrences = [];
	}

	public function renderTag(array $tag, array $options)
	{
		$this->trimAfter = 0;

		$tagName = $tag['tag'];
		if (!isset($this->tags[$tagName]))
		{
			return $this->renderUnparsedTag($tag, $options);
		}

		$renderRule = $this->tags[$tagName];
		$rule = $this->rules->getTag($tagName);

		if ($rule && !empty($rule['stopSmilies']))
		{
			if (is_int($options['stopSmilies']))
			{
				$options['stopSmilies']++;
			}
			else
			{
				$options['stopSmilies'] = true;
			}
		}
		if (!empty($renderRule['stopBreakConversion']))
		{
			if (is_int($options['stopBreakConversion']))
			{
				$options['stopBreakConversion']++;
			}
			else
			{
				$options['stopBreakConversion'] = true;
			}
		}
		if (!empty($rule['plain']))
		{
			$options['plain'] = true;
		}

		if (!empty($renderRule['callback']))
		{
			if (is_string($renderRule['callback']))
			{
				$renderRule['callback'] = [$this, $renderRule['callback']];
			}
			if (is_callable($renderRule['callback']))
			{
				$output = call_user_func(
					$renderRule['callback'],
					$tag['children'],
					$tag['option'],
					$tag,
					$options,
					$this
				);
			}
			else
			{
				$output = $this->renderUnparsedTag($tag, $options);
			}
		}
		else if (!empty($renderRule['replace']))
		{
			$text = $this->renderSubTree($tag['children'], $options);
			if (empty($renderRule['keepEmpty']) && trim($text) === '')
			{
				return $text;
			}

			if ($tag['option'] !== null)
			{
				$optionOutput = $this->filterString($tag['option'], [
					'stopSmilies' => 1,
					'stopBreakConversion' => 1,
					'plain' => true,
				] + $options);
			}
			else
			{
				$optionOutput = '';
			}

			if (is_array($renderRule['replace']))
			{
				[$prepend, $append] = $renderRule['replace'];
				$output = $this->wrapHtml($prepend, $text, $append, $optionOutput);
			}
			else
			{
				$output = strtr($renderRule['replace'], [
					'{text}' => $text,
					'{option}' => $optionOutput,
				]);
			}
		}
		else
		{
			$output = $this->renderUnparsedTag($tag, $options);
		}

		if (!empty($renderRule['trimAfter']))
		{
			if (is_callable($renderRule['trimAfter']))
			{
				$this->trimAfter = $renderRule['trimAfter']($tag['option'], $tag);
			}
			else
			{
				$this->trimAfter = $renderRule['trimAfter'];
			}
		}

		return $output;
	}

	public function renderString($string, array $options)
	{
		if ($this->trimAfter)
		{
			$string = preg_replace('#^([ \t]*\r?\n){1,' . $this->trimAfter . '}#i', '', $string);
			$this->trimAfter = 0;
		}

		return $this->filterString($string, $options);
	}

	public function filterString($string, array $options)
	{
		$string = $this->formatter->censorText($string);

		if (
			!empty($options['stopSmilies'])
			|| !empty($options['plain'])
			|| !empty($options['treatAsStructuredText']) // smilie conversion can break mentions
		)
		{
			$allowSmilies = false;
		}
		else
		{
			$allowSmilies = true;
		}

		if ($allowSmilies)
		{
			$string = $this->formatter->replaceSmiliesHtml($string, $options['smilieFormat']);
			if ($options['replaceEmojis'])
			{
				$string = $this->formatter->getEmojiFormatter()->formatEmojiToImage(
					$string,
					['wrapNative' => true]
				);
			}
		}
		else
		{
			$string = htmlspecialchars($string);
		}

		if (!empty($options['treatAsStructuredText']) && empty($options['plain']))
		{
			$string = $this->formatter->autoLinkStructuredText($string);
			$string = $this->formatter->linkStructuredTextMentions($string);
		}

		if (empty($options['stopBreakConversion']))
		{
			$string = nl2br($string);
		}

		return $string;
	}

	public function filterFinalOutput($output)
	{
		return '<div class="bbWrapper">' . trim($output) . '</div>';
	}

	public function wrapHtml($open, $inner, $close, $option = null)
	{
		if ($option !== null)
		{
			$open = sprintf($open, $option);
			$close = sprintf($close, $option);
		}

		return $open . $inner . $close;
	}

	protected function endsInBlockTag($text)
	{
		return preg_match('#</(p|div|blockquote|pre|ol|ul)>$#i', substr(rtrim($text), -20));
	}

	public function renderTagAlign(array $children, $option, array $tag, array $options)
	{
		$output = trim($this->renderSubTree($children, $options));

		$invisibleSpace = $this->endsInBlockTag($output) ? '' : '&#8203;';

		switch (strtolower($tag['tag']))
		{
			case 'left':
			case 'center':
			case 'right':
			case 'justify':
				return $this->wrapHtml(
					'<div style="text-align: ' . $tag['tag'] . '">',
					$output . $invisibleSpace,
					'</div>'
				);

			default:
				return $this->wrapHtml('<div>', $output . $invisibleSpace, '</div>');
		}
	}

	public function renderTagAttach(array $children, $option, array $tag, array $options)
	{
		$id = intval($this->renderSubTreePlain($children));
		if (!$id)
		{
			return '';
		}

		/** @var Attachment|null $attachment */
		$attachment = $options['attachments'][$id] ?? null;

		$canView = !empty($options['viewAttachments']);
		$full = $this->isFullAttachView($option);

		$processOptions = $this->getDefaultImageDisplayOptions($options);
		if ($full && !$canView)
		{
			$processOptions['skipDimensions'] = true;
		}

		if ($attachment)
		{
			$intrinsicDims = null;

			if ($full)
			{
				$intrinsicDims = [$attachment->width, $attachment->height];
			}
			else if ($attachment->has_thumbnail)
			{
				$intrinsicDims = [$attachment->thumbnail_width, $attachment->thumbnail_height];
			}

			$processOptions['intrinsicDimensions'] = $intrinsicDims;
		}

		$displayAttrs = $this->processImageDisplayModifiers($option, $processOptions);
		$alt = $displayAttrs['alt'] ?? '';

		$viewParams = [
			'id' => $id,
			'attachment' => $attachment,
			'canView' => $canView,
			'full' => $full,
			'alignClass' => $displayAttrs['class'] ?? '',
			'styleAttr' => $displayAttrs['style'] ?? '',
			'alt' =>  $alt ?: ($attachment ? $attachment->filename : ''),
			'displayAttrs' => $displayAttrs,
		];

		if (empty($options['lightbox']))
		{
			$viewParams['noLightbox'] = true;
		}

		return trim($this->getRenderedAttachment($attachment, $viewParams));
	}

	protected function getRenderedAttachment($attachment, array $viewParams)
	{
		return $this->templater->renderTemplate('public:bb_code_tag_attach', $viewParams);
	}

	protected function isFullAttachView($option)
	{
		if ($option)
		{
			if (is_array($option) && isset($option['type']) && strtolower($option['type']) == 'full')
			{
				return true;
			}
			else if (is_string($option) && strtolower($option) == 'full')
			{
				return true;
			}
		}

		return false;
	}

	public function renderTagCode(array $children, $option, array $tag, array $options)
	{
		$content = $this->renderSubTree($children, $options);
		// a bit like ltrim, but only remove blank lines, not leading tabs on the first line
		$content = preg_replace('#^([ \t]*\r?\n)+#', '', $content);
		$content = rtrim($content);

		/** @var CodeLanguage $codeLanguages */
		$codeLanguages = \XF::app()->data(CodeLanguage::class);
		$allowedLanguages = $codeLanguages->getSupportedLanguages(true);

		if ($option)
		{
			$language = strtolower(preg_replace('#[^a-z0-9_-]#i', '-', $option));
		}
		else
		{
			$language = null;
		}

		if ($language && isset($allowedLanguages[$language]))
		{
			$config = $allowedLanguages[$language];
		}
		else
		{
			$config = [];
			if ($language == 'rich')
			{
				$config['phrase'] = \XF::phrase('code_language.rich');
			}
			$language = '';
		}

		if (!is_array($this->allowedCodeLanguages))
		{
			$this->allowedCodeLanguages = Arr::stringToArray(\XF::options()->allowedCodeLanguages, '/\r?\n/');
		}


		if (!in_array($language, $this->allowedCodeLanguages))
		{
			$language = '';
		}

		return $this->getRenderedCode($content, $language, $config);
	}

	protected function getRenderedCode($content, $language, array $config = [])
	{
		return $this->templater->renderTemplate('public:bb_code_tag_code', [
			'content' => new PreEscaped($content),
			'language' => $language,
			'config' => $config,
		]);
	}

	public function renderTagInlineCode(array $children, $option, array $tag, array $options)
	{
		$content = $this->renderSubTree($children, $options);
		$this->templater->includeCss('public:bb_code.less');
		return $this->wrapHtml('<code class="bbCodeInline">', $content, '</code>');
	}

	public function renderTagEmail(array $children, $option, array $tag, array $options)
	{
		if ($option !== null)
		{
			$options['lightbox'] = false;

			$email = $option;
			$text = $this->renderSubTree($children, $options);
		}
		else
		{
			$email = $this->renderSubTreePlain($children);
			$text = $this->filterString($email, $options);
		}

		if (strpos($email, '@') === false)
		{
			// invalid URL, ignore
			return $text;
		}

		$censored = $this->formatter->censorText($email);
		if ($censored !== $email)
		{
			return $text;
		}

		return $this->wrapHtml(
			'<a href="mailto:' . htmlspecialchars($email) . '">',
			$text,
			'</a>'
		);
	}

	public function renderTagHtml(array $children, $option, array $tag, array $options)
	{
		return $this->renderTagCode($children, 'html', $tag, $options);
	}

	public function renderTagImage(array $children, $option, array $tag, array $options)
	{
		$url = $this->renderSubTreePlain($children);

		$censored = $this->formatter->censorText($url);
		if ($censored !== $url)
		{
			return $this->filterString($url, $options);
		}

		$validUrl = $this->getValidUrl($url);
		if (!$validUrl)
		{
			return $this->filterString($url, $options);
		}

		if ($options['noProxy'])
		{
			$imageUrl = $validUrl;
		}
		else
		{
			$linkInfo = $this->formatter->getLinkClassTarget($validUrl);
			if ($linkInfo['local'])
			{
				$imageUrl = $validUrl;
			}
			else
			{
				$imageUrl = $this->formatter->getProxiedUrlIfActive('image', $validUrl);
				if (!$imageUrl)
				{
					$imageUrl = $validUrl;
				}
			}
		}

		$displayAttrs = $this->processImageDisplayModifiers(
			$option,
			$this->getDefaultImageDisplayOptions($options)
		);

		$alt = $displayAttrs['alt'] ?? '';

		$altFallback = $alt;
		if ($alt === '')
		{
			$urlPath = @parse_url($validUrl, PHP_URL_PATH);
			if ($urlPath)
			{
				$altFallback = basename($urlPath);
			}
		}

		$width = $displayAttrs['width'] ?? '';
		$height = $displayAttrs['height'] ?? '';

		if (empty($displayAttrs['width']) && empty($displayAttrs['height']))
		{
			$hash = md5($validUrl);
			$image = $options['images'][$hash] ?? null;

			if ($image)
			{
				$width = $image['width'] ?? '';
				$height = $image['height'] ?? '';
			}
		}

		$params = [
			'lightbox' => !empty($options['lightbox']),
			'alignClass' => $displayAttrs['class'] ?? '',
			'styleAttr' => $displayAttrs['style'] ?? '',
			'alt' => $alt,
			'altFallback' => $altFallback,
			'width' => $width,
			'height' => $height,
			'size' => $option['size'] ?? '',
			'displayAttrs' => $displayAttrs,
		];

		return trim($this->getRenderedImg($imageUrl, $validUrl, $params));
	}

	protected function getRenderedImg($imageUrl, $validUrl, array $params = [])
	{
		$params['imageUrl'] = $imageUrl;
		$params['validUrl'] = $validUrl;

		return $this->templater->renderTemplate('public:bb_code_tag_img', $params);
	}

	protected function getDefaultImageDisplayOptions(array $renderOptions = []): array
	{
		return [];
	}

	protected function processImageDisplayModifiers($modifiers, array $processOptions = []): array
	{
		if (!is_array($modifiers))
		{
			return [];
		}

		$processOptions = array_replace([
			'leftAlignClass' => 'bbImageAligned--left',
			'rightAlignClass' => 'bbImageAligned--right',
			'allowIntrinsicDimensions' => true,
			'intrinsicDimensions' => null,
			'skipDimensions' => false,
		], $processOptions);

		$output = [];

		$alignOption = $this->hasAlignOption($modifiers);
		if ($alignOption)
		{
			switch ($alignOption)
			{
				case 'L':
					$output['class'] = $processOptions['leftAlignClass'];
					break;

				case 'R':
					$output['class'] = $processOptions['rightAlignClass'];
					break;
			}
		}

		if (!empty($modifiers['alt']))
		{
			$output['alt'] = $modifiers['alt'];
		}
		else if (!empty($modifiers['title']))
		{
			// always going into alt
			$output['alt'] = $modifiers['title'];
		}

		if ($processOptions['skipDimensions'])
		{
			$width = null;
			$height = null;
		}
		else
		{
			if (isset($modifiers['width']) && preg_match('/^[\d\.]+(px|%)$/i', $modifiers['width']))
			{
				$width = $modifiers['width'];
			}
			else
			{
				$width = null;
			}

			if (isset($modifiers['height']) && preg_match('/^[\d\.]+(px|%)$/i', $modifiers['height']))
			{
				$height = $modifiers['height'];
			}
			else
			{
				$height = null;
			}
		}

		if (
			$processOptions['allowIntrinsicDimensions']
			&& $width !== null
			&& preg_match('/px$/i', $width)
			&& $height !== null
			&& preg_match('/px$/i', $height)
		)
		{
			// we have pixel values for height and width. Use them for intrinsic sizing
			$output['width'] = substr($width, 0, -2);
			$output['height'] = substr($height, 0, -2);

			// without this, we'll use the intrinsic values but our width: auto CSS with prevent the resize
			// from actually applying
			$output['style'] = "width: $width";
		}
		else
		{
			$style = [];

			if ($width !== null)
			{
				$style[] = "width: $width";
			}
			if ($height !== null)
			{
				$style[] = "height: $height";

				if ($width === null && $processOptions['intrinsicDimensions'])
				{
					// if we have intrinsic dimensions, outputting just height is unlikely to
					// resize as expected (see XF-192612).
					$style[] = "width: auto";
				}
			}

			if ($style)
			{
				$output['style'] = implode('; ', $style);
			}
		}

		return $output;
	}

	/**
	 * Returns R, L or false
	 */
	protected function hasAlignOption($option)
	{
		if (is_array($option) && isset($option['align']) && strlen($option['align']))
		{
			return strtoupper($option['align'][0]);
		}

		return false;
	}

	public function renderTagIndent(array $children, $option, array $tag, array $options)
	{
		$output = trim($this->renderSubTree($children, $options));

		$amount = $option ? intval($option) : 1;
		$amount = max(1, min($amount, 5));

		$invisibleSpace = $this->endsInBlockTag($output) ? '' : '&#8203;';

		$side = \XF::language()->isRtl() ? 'right' : 'left';
		return $this->wrapHtml(
			'<div style="margin-' . $side . ': ' . ($amount * 20) . 'px">',
			$output . $invisibleSpace,
			'</div>'
		);
	}

	public function renderTagHeading(array $children, $option, array $tag, array $options)
	{
		$output = trim($this->renderSubTree($children, $options));
		$invisibleSpace = $this->endsInBlockTag($output) ? '' : '&#8203;';

		$type = $option ? intval($option) : 0;
		$tag = $this->getHeadingTagMap($type);

		$anchor = trim($this->renderSubTreePlain($children));
		$anchor = $this->formatter->stripBbCode($anchor);
		$router = \XF::app()->router('public');
		$anchor = $router->prepareStringForUrl($anchor);
		$anchor = '-' . $anchor;

		if ($anchor && !empty($options['showHeadingAnchors']))
		{
			if (isset($this->anchorOccurrences[$anchor]))
			{
				$this->anchorOccurrences[$anchor]++;
				$anchor .= '-' . $this->anchorOccurrences[$anchor];
			}
			else
			{
				$this->anchorOccurrences[$anchor] = 1;
			}

			$title = htmlspecialchars(\XF::phrase('permanent_link'));

			return $this->wrapHtml(
				"<$tag class=\"bbHeading\"><a class=\"u-anchorTarget\" name=\"$anchor\"></a>",
				$output . $invisibleSpace,
				"<a class=\"hoverLink\" href=\"#$anchor\" title=\"$title\"></a></$tag>"
			);
		}
		else
		{
			return $this->wrapHtml("<$tag class=\"bbHeading\">", $output . $invisibleSpace, "</$tag>");
		}
	}

	public function renderTagHr(array $children, $option, array $tag, array $options)
	{
		$output = trim($this->renderSubTree($children, $options));

		return '<hr />' . (strlen($output) ? "\n$output" : '');
	}

	protected function getHeadingTagMap(int $type): string
	{
		switch ($type)
		{
			case 1: return 'h2';
			case 2: return 'h3';
			case 3: return 'h4';
			default: return 'div';
		}
	}

	public function renderTagList(array $children, $option, array $tag, array $options)
	{
		$listType = ($option && $option === '1' ? 'ol' : 'ul');
		$elements = [];
		$lastElement = '';

		foreach ($children AS $child)
		{
			if (is_array($child))
			{
				$childText = $this->renderTag($child, $options);
				if (preg_match('#^<(ul|ol)#', $childText))
				{
					$lastElement = rtrim($lastElement);
					if (substr($lastElement, -6) == '<br />')
					{
						$lastElement = substr($lastElement, 0, -6);
					}
				}
				$lastElement .= $childText;
			}
			else
			{
				if (strpos($child, '[*]') !== false)
				{
					$parts = explode('[*]', $child);

					$beforeFirst = array_shift($parts);
					if ($lastElement !== '' || trim($beforeFirst) !== '')
					{
						$lastElement .= $this->renderString($beforeFirst, $options);
					}

					foreach ($parts AS $part)
					{
						$this->appendListElement($elements, $lastElement);
						$lastElement = $this->renderString($part, $options);
					}
				}
				else
				{
					$lastElement .= $this->renderString($child, $options);
				}
			}
		}

		$this->appendListElement($elements, $lastElement);

		if (!$elements)
		{
			return '';
		}

		return $this->renderFinalListOutput($listType, $elements);
	}

	protected function appendListElement(array &$elements, $appendString)
	{
		if ($appendString !== '')
		{
			$appendString = rtrim($appendString);
			if (substr($appendString, -6) == '<br />')
			{
				$appendString = substr($appendString, 0, -6);
			}

			$elements[] = $appendString;
		}
	}

	protected function renderFinalListOutput($listType, array $elements)
	{
		$output = "<$listType>";
		foreach ($elements AS $element)
		{
			$output .= "\n<li data-xf-list-type=\"$listType\">$element</li>";
		}
		$output .= "\n</$listType>";

		return $output;
	}

	public function renderTagMedia(array $children, $option, array $tag, array $options)
	{
		$mediaKey = trim($this->renderSubTreePlain($children));
		if (preg_match('#[&?"\'<>\r\n]#', $mediaKey) || strpos($mediaKey, '..') !== false)
		{
			return '';
		}

		$censored = $this->formatter->censorText($mediaKey);
		if ($censored != $mediaKey)
		{
			return '';
		}

		$mediaSiteId = strtolower($option);
		if ($this->isLegacyMediaSite($mediaSiteId))
		{
			return $this->renderLegacyMediaSite($mediaSiteId, $mediaKey);
		}
		if (!isset($this->mediaSites[$mediaSiteId]))
		{
			return '';
		}

		$site = $this->mediaSites[$mediaSiteId];

		$embedHtml = '';

		if (!empty($site['callback']) && is_callable($site['callback']))
		{
			$embedHtml = call_user_func($site['callback'], $mediaKey, $site, $mediaSiteId);
		}

		$keyDigits = preg_replace('/[^0-9]/', '', $mediaKey);
		$keyEncoded = rawurlencode($mediaKey);
		$keySlash = str_replace('%2F', '/', $keyEncoded);

		if (!$embedHtml)
		{
			if ($site['oembed_enabled'])
			{
				$embedHtml = $this->templater->renderTemplate('public:_media_site_embed_oembed', [
					'provider' => $mediaSiteId,
					'id' => $mediaKey,
					'site' => $site,
					'jsState' => $mediaSiteId,
					'url' => strtr($site['oembed_url_scheme'], [
						'{$id}' => $keyEncoded,
						'{$idSlash}' => $keySlash,
						'{$idDigits}' => $keyDigits,
					]),
				]);
			}
			else
			{
				$embedHtml = $this->templater->renderTemplate('public:_media_site_embed_' . $mediaSiteId, [
					'siteId'   => $mediaSiteId,
					'id'       => $keyEncoded,
					'idRaw'    => $mediaKey,
					'idSlash'  => $keySlash,
					'idDigits' => $keyDigits,
				]);
			}
		}

		$cookieThirdParties = array_filter(preg_split(
			'/\r?\n/',
			$site['cookie_third_parties'] ?? ''
		));
		if ($cookieThirdParties && empty($options['noCookieConsent']))
		{
			$cookieConsent = \XF::app()->cookieConsent();
			$unconsentedThirdParties = $cookieConsent->getUnconsentedThirdParties(
				$cookieThirdParties
			);
			if ($unconsentedThirdParties)
			{
				return $this->templater->renderMacro(
					'public:misc_cookies',
					'third_party_consent_form',
					[
						'unconsented' => $unconsentedThirdParties,
						'embedHtml' => $embedHtml,
						'siteId'    => $mediaSiteId,
						'id'        => $keyEncoded,
					]
				);
			}
		}

		return $embedHtml;
	}

	protected function isLegacyMediaSite(string $mediaSiteId): bool
	{
		return in_array($mediaSiteId, $this->legacyMediaSites);
	}

	protected function renderLegacyMediaSite(string $mediaSiteId, string $mediaKey): string
	{
		return $this->templater->renderTemplate('public:_media_site_legacy_embed', [
			'siteId'   => $mediaSiteId,
			'id'       => $mediaKey,
		]);
	}

	public function renderTagPhp(array $children, $option, array $tag, array $options)
	{
		return $this->renderTagCode($children, 'php', $tag, $options);
	}

	public function renderTagQuote(array $children, $option, array $tag, array $options)
	{
		if (!$children)
		{
			return '';
		}

		$this->trimChildrenList($children);

		$content = $this->renderSubTree($children, array_merge($options, [
			'noFollowUrl' => true,
			'showHeadingAnchors' => false,
		]));
		if ($content === '')
		{
			return '';
		}

		$quoteParams = $this->parseQuoteTagParams($option, $options);

		return $this->getRenderedQuote(
			$content,
			$quoteParams['name'],
			$quoteParams['source'],
			$quoteParams['attributes']
		);
	}

	protected function parseQuoteTagParams($params, array $options)
	{
		$name = null;
		$attributes = [];
		$source = [];

		if ($params !== null && strlen($params))
		{
			$parts = explode(',', $params);
			$name = $this->filterString(array_shift($parts), array_merge($options, [
				'stopSmilies' => 1,
				'stopBreakConversion' => 1,
			]));

			foreach ($parts AS $part)
			{
				$attributeParts = explode(':', $part, 2);
				if (isset($attributeParts[1]))
				{
					$attrName = trim($attributeParts[0]);
					$attrValue = trim($attributeParts[1]);
					if ($attrName !== '' && $attrValue !== '')
					{
						$attributes[$attrName] = $attrValue;
					}
				}
			}

			if ($attributes)
			{
				$firstValue = reset($attributes);
				$firstName = key($attributes);
				if ($firstName != 'member')
				{
					$source = ['type' => $firstName, 'id' => intval($firstValue)];
				}
			}
		}

		return [
			'name' => $name,
			'source' => $source,
			'attributes' => $attributes,
		];
	}

	protected function getRenderedQuote($content, $name, array $source, array $attributes)
	{
		return $this->templater->renderTemplate('public:bb_code_tag_quote', [
			'content' => new PreEscaped($content),
			'name' => $name ? new PreEscaped($name) : null,
			'source' => $source,
			'attributes' => $attributes,
		]);
	}

	public function renderTagColor(array $children, $option, array $tag, array $options)
	{
		$text = $this->renderSubTree($children, $options);
		if (trim($text) === '')
		{
			return $text;
		}

		if (!$this->isUsableColor($option))
		{
			return $text;
		}

		$color = htmlspecialchars($option);

		return $this->wrapHtml(
			'<span style="color: ' . $color . '">',
			$text,
			'</span>'
		);
	}

	protected function isUsableColor(string $color): bool
	{
		switch (strtolower($color))
		{
			case 'transparent':
				// a special case, not really appropriate in most cases; spoilers are better
				return false;

			case 'activeborder':
			case 'activecaption':
			case 'activetext':
			case 'appworkspace':
			case 'background':
			case 'buttonface':
			case 'buttonhighlight':
			case 'buttonshadow':
			case 'buttontext':
			case 'canvas':
			case 'canvastext':
			case 'captiontext':
			case 'field':
			case 'fieldtext':
			case 'graytext':
			case 'highlight':
			case 'highlighttext':
			case 'inactiveborder':
			case 'inactivecaption':
			case 'inactivecaptiontext':
			case 'infobackground':
			case 'infotext':
			case 'linktext':
			case 'mark':
			case 'marktext':
			case 'menu':
			case 'menutext':
			case 'scrollbar':
			case 'threeddarkshadow':
			case 'threedface':
			case 'threedhighlight':
			case 'threedlightshadow':
			case 'threedshadow':
			case 'window':
			case 'windowframe':
			case 'windowtext':
			case 'visitedtext':
				// all system colors which may not be consistent, many are deprecated
				return false;

			default:
				return true;
		}
	}

	public function renderTagFont(array $children, $option, array $tag, array $options)
	{
		$text = $this->renderSubTree($children, $options);
		if (trim($text) === '')
		{
			return $text;
		}

		$font = htmlspecialchars($option);

		if (strtolower(trim($font)) != 'inherit')
		{
			$font = "'{$font}'";
		}

		return $this->wrapHtml(
			'<span style="font-family: ' . $font . '">',
			$text,
			'</span>'
		);
	}

	public function renderTagSize(array $children, $option, array $tag, array $options)
	{
		$text = $this->renderSubTree($children, $options);
		if (trim($text) === '')
		{
			return $text;
		}

		$size = $this->getTextSize($option);
		if ($size)
		{
			return $this->wrapHtml(
				'<span style="font-size: ' . htmlspecialchars($size) . '">',
				$text,
				'</span>'
			);
		}
		else
		{
			return $text;
		}
	}

	protected function getTextSize($inputSize)
	{
		if (strval(intval($inputSize)) == strval($inputSize))
		{
			// int only, translate size
			if ($inputSize <= 0)
			{
				return null;
			}

			switch ($inputSize)
			{
				case 1: return '9px';
				case 2: return '10px';
				case 3: return '12px';
				case 4: return '15px';
				case 5: return '18px';
				case 6: return '22px';
				default: return '26px';
			}
		}
		else
		{
			// int and unit
			if (!preg_match('/^([0-9]+)px$/i', $inputSize, $match))
			{
				return null;
			}

			$size = intval($match[1]);
			$size = max(8, min($size, 36));

			return $size . 'px';
		}
	}

	public function renderTagSpoiler(array $children, $option, array $tag, array $options)
	{
		if (!$children)
		{
			return '';
		}

		$this->trimChildrenList($children);

		$content = $this->renderSubTree($children, $options);
		if ($content === '')
		{
			return '';
		}

		if ($option)
		{
			$title = $this->filterString($option, array_merge($options, [
				'stopSmilies' => 1,
				'stopBreakConversion' => 1,
			]));
		}
		else
		{
			$title = null;
		}

		return $this->getRenderedSpoiler($content, $title);
	}

	protected function getRenderedSpoiler($content, $title = null)
	{
		return $this->templater->renderTemplate('public:bb_code_tag_spoiler', [
			'content' => new PreEscaped($content),
			'title' => $title ? new PreEscaped($title) : null,
		]);
	}

	public function renderTagInlineSpoiler(array $children, $option, array $tag, array $options)
	{
		$options['allowUnfurl'] = false;
		$content = $this->renderSubTree($children, $options);
		return $this->getRenderedInlineSpoiler($content);
	}

	protected function getRenderedInlineSpoiler($content)
	{
		$this->templater->includeCss('public:bb_code.less');

		return $this->wrapHtml(
			'<span class="bbCodeInlineSpoiler" data-xf-click="toggle-class" data-class="bbCodeInlineSpoiler" tabindex="0" role="button">',
			$content,
			'</span>'
		);
	}

	public function renderTagUrl(array $children, $option, array $tag, array $options)
	{
		$unfurl = false;

		if ($option !== null && !is_array($option))
		{
			$options['lightbox'] = false;

			$url = $option;
			$text = $this->renderSubTree($children, $options);

			if ($text === '')
			{
				$text = $this->prepareTextFromUrlExtended($url, $options);
			}
		}
		else if (is_array($option)
			&& isset($option['unfurl'])
			&& $option['unfurl'] === 'true'
			&& !empty($options['allowUnfurl'])
		)
		{
			$url = $this->renderSubTreePlain($children);
			$text = '<div>' . $this->prepareTextFromUrlExtended($url, $options) . '</div>';
			$unfurl = true;
		}
		else
		{
			$url = $this->renderSubTreePlain($children);
			$text = $this->prepareTextFromUrlExtended($url, $options);
		}

		$censored = $this->formatter->censorText($url);
		if ($censored !== $url)
		{
			return $text;
		}

		$url = $this->getValidUrl($url);
		if (!$url)
		{
			return $text;
		}

		if ($unfurl)
		{
			return $this->getRenderedUnfurl($url, $options);
		}
		else
		{
			return $this->getRenderedLink($text, $url, $options);
		}
	}

	protected function prepareTextFromUrlExtended($url, array $options)
	{
		$text = rawurldecode($url);
		if (!preg_match('/./su', $text))
		{
			$text = $url;
		}
		$text = $this->formatter->censorText($text);

		if (!empty($options['shortenUrl']))
		{
			$length = Str::strlen($text);
			if ($length > 100)
			{
				$text = Str::substr_replace($text, '...', 35, $length - 35 - 45);
			}
		}

		return htmlspecialchars($text);
	}

	protected function getRenderedLink($text, $url, array $options)
	{
		$linkInfo = $this->formatter->getLinkClassTarget($url);
		$rels = [];

		$classAttr = $linkInfo['class'] ? " class=\"$linkInfo[class]\"" : '';
		$targetAttr = $linkInfo['target'] ? " target=\"$linkInfo[target]\"" : '';

		if (!$linkInfo['trusted'] && !empty($options['noFollowUrl']))
		{
			$rels[] = 'nofollow';
			$rels[] = 'ugc';
		}

		if ($linkInfo['target'])
		{
			$rels[] = 'noopener';
		}

		$proxyAttr = '';
		if (empty($options['noProxy']))
		{
			$proxyUrl = $this->formatter->getProxiedUrlIfActive('link', $url);
			if ($proxyUrl)
			{
				$proxyAttr = ' data-proxy-href="' . htmlspecialchars($proxyUrl) . '"';
			}
		}

		if ($rels)
		{
			$relAttr = ' rel="' . implode(' ', $rels) . '"';
		}
		else
		{
			$relAttr = '';
		}

		return $this->wrapHtml(
			'<a href="' . htmlspecialchars($url) . '"' . $targetAttr . $classAttr . $proxyAttr . $relAttr . '>',
			$text,
			'</a>'
		);
	}

	protected function getRenderedUnfurl($url, array $options)
	{
		$result = $this->getUnfurlResultFromUrl($url, $options);

		if (!$result)
		{
			$text = $this->prepareTextFromUrlExtended($url, $options);
			return '<div>' . $this->getRenderedLink($text, $url, $options) . '</div>';
		}

		return $this->templater->renderUnfurl($result, $options);
	}

	protected function getUnfurlResultFromUrl($url, array $options)
	{
		$urlHash = md5($url);

		if (isset($options['unfurls'][$urlHash]))
		{
			$result = $options['unfurls'][$urlHash];
		}
		else
		{
			$unfurlRepo = \XF::app()->repository(UnfurlRepository::class);
			$result = $unfurlRepo->getUnfurlResultByUrl($url);
		}

		return $result;
	}

	protected function getValidUrl($url)
	{
		return Url::getValidUrl($url, $this->allowedUrlProtocolRegex);
	}

	public function renderTagUser(array $children, $option, array $tag, array $options)
	{
		$content = $this->renderSubTree($children, $options);
		if ($content === '')
		{
			return '';
		}

		$userId = intval($option);
		if ($userId <= 0)
		{
			return $content;
		}

		return $this->getRenderedUser($content, $userId);
	}

	protected function getRenderedUser($content, int $userId)
	{
		$link = \XF::app()->router('public')->buildLink('full:members', ['user_id' => $userId]);

		return $this->wrapHtml(
			'<a href="' . htmlspecialchars($link) . '" class="username" data-xf-init="member-tooltip" data-user-id="' . $userId . '" data-username="' . $content . '">',
			$content,
			'</a>'
		);
	}

	public function renderTagTable(array $children, $option, array $tag, array $options)
	{
		$rows = [];
		$columnCounts = [];
		$lostAndFound = [];
		foreach ($children AS $child)
		{
			if (is_array($child))
			{
				if ($child['tag'] === 'tr')
				{
					$rows[] = $this->renderTableRow($child, $options, $columnCount, $lostAndFound);
					$columnCounts[] = $columnCount;
				}
				else
				{
					$lostAndFound[] = $this->renderSubTree([$child], $options);
				}
			}
			else if (trim($child) !== '')
			{
				$lostAndFound[] = $this->renderSubTree([$child], $options);
			}
		}

		$maxColumnCount = max($columnCounts ?: [0]);
		foreach ($columnCounts AS $i => $columnCount)
		{
			if ($columnCount < $maxColumnCount)
			{
				$td = strpos($rows[$i], '<th') !== false ? 'th' : 'td';
				$filler = str_repeat("<$td></$td>", $maxColumnCount - $columnCount);
				$rows[$i] = preg_replace('#</tr>$#', "$filler\\0", $rows[$i]);
			}
		}

		return $this->renderFinalTableHtml(implode('', $rows), $option, implode("\n", $lostAndFound));
	}

	protected function renderFinalTableHtml($tableHtml, $tagOption, $extraContent)
	{
		$width = $this->filterTableWidth($tagOption['width'] ?? null) ?? '100%';
		return "<div class=\"bbTable\">\n<table style='width: $width'>$tableHtml</table>\n$extraContent</div>";
	}

	protected function filterTableWidth($width): ?string
	{
		if (is_string($width) && preg_match('/^[\d\.]+(px|pt|em|rem|%)$/i', $width))
		{
			return $width;
		}

		return null;
	}

	protected function renderTableRow(array $tag, array $options, &$columnCount, array &$lostAndFound)
	{
		$output = '';
		$columnCount = 0;
		foreach ($tag['children'] AS $child)
		{
			if (is_array($child))
			{
				if ($child['tag'] === 'td' || $child['tag'] === 'th')
				{
					$output .= $this->renderTableCell($child, $options);
					$columnCount++;
				}
				else
				{
					$lostAndFound[] = $this->renderSubTree([$child], $options);
				}
			}
			else if (trim($child) !== '')
			{
				$lostAndFound[] = $this->renderSubTree([$child], $options);
			}
		}

		return "<tr>$output</tr>";
	}

	protected function renderTableCell(array $tag, array $options)
	{
		$width = $this->filterTableWidth($tag['option']['width'] ?? null);

		$output = $this->renderSubTree($tag['children'], $options);

		if ($width !== null)
		{
			return "<$tag[tag] style='width: $width'>$output</$tag[tag]>";
		}
		else
		{
			return "<$tag[tag]>$output</$tag[tag]>";
		}
	}

	public function renderTagEmbed(array $children, $option, array $tag, array $options): string
	{
		if (!empty($options['withinEmbedResolver']))
		{
			return '';
		}

		[$contentType, $contentId] = \XF::app()->getContentTypeIdFromString($option['content'] ?? '');

		$embeds = $options['embeds'] ?? [];

		$url = is_string($children[0] ?? null) ? $children[0] : '';
		$censored = $this->formatter->censorText($url);
		$validUrl = $this->getValidUrl($url);

		if ($url && $validUrl && $censored === $url)
		{
			$text = $this->prepareTextFromUrlExtended($validUrl, $options);
			$fallback = '<div>' . $this->getRenderedLink($text, $validUrl, $options) . '</div>';
		}
		else
		{
			$fallback = $this->renderUnparsedTag($tag, $options);
		}

		if (!isset($embeds[$contentType][$contentId]))
		{
			return $fallback;
		}

		/** @var Entity|EmbedResolverTrait $content */
		$content = $embeds[$contentType][$contentId];

		if (!method_exists($content, 'canViewEmbed') || !$content->canViewEmbed())
		{
			return $fallback;
		}

		return $this->templater->renderTemplate('public:bb_code_tag_embed', [
			'content' => $content,
			'contentUrl' => $url,
			'fallback' => $fallback,
		]);
	}

	protected function trimChildrenList(array &$children)
	{
		$keys = array_keys($children);
		$firstKey = reset($keys);
		$lastKey = end($keys);

		if (is_string($children[$firstKey]))
		{
			$children[$firstKey] = ltrim($children[$firstKey]);
		}
		if (is_string($children[$lastKey]))
		{
			$children[$lastKey] = rtrim($children[$lastKey]);
		}
	}

	public static function factory(App $app)
	{
		$renderer = new static($app->stringFormatter(), $app->templater());
		$renderer->addMediaSites($app['bbCode.media']);

		return $renderer;
	}
}
