<?php

namespace XF\Html\Renderer;

use XF\Html\Parser;
use XF\Html\Tag;
use XF\Html\Text;

use function call_user_func, count, in_array, intval, is_array, strlen;

class BbCode
{
	protected $_options = [
		'baseUrl' => '',
	];

	public const BR_SUBSTITUTE = "\x1A";

	/**
	 * A map of tag handlers. Tag names are in lower case. Possible keys:
	 * 		* wrap - wraps tag content in some text; used %s for text (eg, [b]%s[/b])
	 * 		* filterCallback - callback to process tag; given tag content (string) and tag (Tag)
	 *
	 * @var array Key is tag name in lower case
	 */
	protected $_handlers = [
		'span'       => ['filterCallback' => ['$this', 'handleTagSpan']],

		'b'          => ['wrap' => '[B]%s[/B]'],
		'strong'     => ['wrap' => '[B]%s[/B]'],

		'i'          => ['wrap' => '[I]%s[/I]'],
		'em'         => ['wrap' => '[I]%s[/I]'],

		'u'          => ['wrap' => '[U]%s[/U]'],
		's'          => ['wrap' => '[S]%s[/S]'],
		'strike'     => ['wrap' => '[S]%s[/S]'],

		'font'       => ['filterCallback' => ['$this', 'handleTagFont']],
		'a'          => ['filterCallback' => ['$this', 'handleTagA']],
		'img'        => ['filterCallback' => ['$this', 'handleTagImg'], 'skipCss' => true],
		'video'      => ['filterCallback' => ['$this', 'handleTagVideo'], 'skipCss' => true],
		'audio'      => ['filterCallback' => ['$this', 'handleTagAudio'], 'skipCss' => true],

		'ul'         => ['filterCallback' => ['$this', 'handleTagUl'], 'skipCss' => true],
		'ol'         => ['filterCallback' => ['$this', 'handleTagOl'], 'skipCss' => true],
		'li'         => ['filterCallback' => ['$this', 'handleTagLi']],

		'blockquote' => ['filterCallback' => ['$this', 'handleTagBlockquote']],
		'code'       => ['filterCallback' => ['$this', 'handleTagCode'], 'skipCss' => true],

		'h1'         => ['filterCallback' => ['$this', 'handleTagH']],
		'h2'         => ['filterCallback' => ['$this', 'handleTagH']],
		'h3'         => ['filterCallback' => ['$this', 'handleTagH']],
		'h4'         => ['filterCallback' => ['$this', 'handleTagH']],
		'h5'         => ['filterCallback' => ['$this', 'handleTagH']],
		'h6'         => ['filterCallback' => ['$this', 'handleTagH']],

		'hr'         => ['filterCallback' => ['$this', 'handleTagHr'], 'skipCss' => true],

		'table'      => ['filterCallback' => ['$this', 'handleTagTable'], 'skipCss' => true],
		'tr'         => ['wrap' => "[TR]\n%s\n[/TR]", 'skipCss' => true],
		'td'         => ['filterCallback' => ['$this', 'handleTagCell']],
		'th'         => ['filterCallback' => ['$this', 'handleTagCell']],
	];

	/**
	 * Handlers for specific CSS rules. Value is a callback function name.
	 *
	 * @var array Key is the CSS rule name
	 */
	protected $_cssHandlers = [
		'color'           => ['$this', 'handleCssColor'],
		'float'           => ['$this', 'handleCssFloat'],
		'font-family'     => ['$this', 'handleCssFontFamily'],
		'font-size'       => ['$this', 'handleCssFontSize'],
		'font-style'      => ['$this', 'handleCssFontStyle'],
		'font-weight'     => ['$this', 'handleCssFontWeight'],
		'margin-left'     => ['$this', 'handleCssIndentLeft'], // editor implements LTR indent this way
		'margin-right'    => ['$this', 'handleCssIndentRight'], // editor implements RTL indent this way
		'padding-left'    => ['$this', 'handleCssIndentLeft'], // editor implements LTR indent this way
		'padding-right'   => ['$this', 'handleCssIndentRight'], // editor implements RTL indent this way
		'text-align'      => ['$this', 'handleCssTextAlign'],
		'text-decoration' => ['$this', 'handleCssTextDecoration'],
	];

	public static function renderFromHtml($html, array $options = [])
	{
		//echo '<pre>' . htmlspecialchars($html) . '</pre>'; exit;

		$class = \XF::app()->extendClass(self::class);
		/** @var $renderer BbCode */
		$renderer = new $class($options);

		$html = $renderer->preFilter($html);

		$parser = new Parser($html);
		$parsed = $parser->parse();

		//$parser->printTags($parsed);

		$rendered = $renderer->render($parsed);
		//echo '<pre>' . htmlspecialchars($rendered) . '</pre>'; exit;

		return $rendered;
	}

	/**
	 * Constructor.
	 *
	 * @param array $options
	 */
	public function __construct(array $options = [])
	{
		$this->_options['baseUrl'] = \XF::app()->request()->getFullBasePath();

		$this->_options = array_merge($this->_options, $options);
	}

	public function preFilter($html)
	{
		// IE bug (#25781)
		$html = preg_replace(
			'#(<a[^>]+href="([^"]+)"[^>]*>)\\2(\[/?[a-z0-9_-]+)(</a>)\]#siU',
			'$1$2$4$3]',
			$html
		);

		// issue where URLs have been auto linked inside manually entered BB code options
		$html = preg_replace(
			'#(\[[a-z0-9_-]+=("|\'|))<a[^>]+href="([^"]+)"[^>]*>\\3</a>#siU',
			'$1$3',
			$html
		);

		$html = preg_replace_callback(
			'#(\[(code|php|html|img|plain)\])(.*)(\[/\\2\])#siU',
			[$this, '_stripStylingHtmlMatch'],
			$html
		);

		$html = preg_replace(
			'#<div class="bbCodeBlock bbCodeBlock--unfurl[^"]+".*data-url="(.*)"[^>]*>#miU',
			'[URL unfurl="true"]$1[/URL]',
			$html
		);

		$html = preg_replace_callback('/^<li\s?(?:data-xf-list-type="(ul|ol)")?>.*<\/li>$/is', function (array $match)
		{
			$type = $match[1] ?? 'ul';
			return "<$type>$match[0]</$type>";
		}, $html);

		// discard outer span.fr-video tag and apply some properties directly to video
		$html = preg_replace_callback('/<span[^>]*class="(fr-video[^"]*)"[^>]*>(<video.*)<\/span>/U', function (array $match)
		{
			$classes = $match[1];
			$video = $match[2];

			$video = str_replace('class="', 'class="' . $classes . ' ', $video);

			return $video;
		}, $html);

		// discard outer span.fr-audio tag and apply some properties directly to audio
		$html = preg_replace_callback('/<span[^>]*class="(fr-audio[^"]*)"[^>]*>(<audio.*)<\/span>/U', function (array $match)
		{
			$classes = $match[1];
			$video = $match[2];

			$video = str_replace('class="', 'class="' . $classes . ' ', $video);

			return $video;
		}, $html);

		return $html;
	}

	protected function _stripStylingHtmlMatch(array $match)
	{
		$content = $match[3];
		$tags = 'b|i|u|s|strong|em|strike|a|span|font';

		$content = preg_replace('#<(' . $tags . ')(\s[^>]*)?>#i', '', $content);
		$content = preg_replace('#</(' . $tags . ')>#i', '', $content);

		return $match[1] . $content . $match[4];
	}

	/**
	 * Renders the specified tag and all children.
	 *
	 * @param Tag $tag
	 *
	 * @return string
	 */
	public function render(Tag $tag)
	{
		$output = $this->renderTag($tag);
		return $this->_postRender($output->text());
	}

	protected function _postRender($text)
	{
		$text = \XF::cleanString($text);

		$text = preg_replace('#\[img\]\[url\]([^\[]+)\[/url\]\[/img\]#i', '[IMG]$1[/IMG]', $text);

		do
		{
			$newText = preg_replace('#\[/(b|i|u|s|left|center|right|justify)\]([\r\n]*)\[\\1\]#i', '\\2', $text);
			if ($newText === null || $newText == $text)
			{
				break;
			}
			$text = $newText;
		}
		while (true);

		do
		{
			$newText = preg_replace('#(\[(font|color|size)=[^\]]*\])((?:(?>[^\[]+?)|\[(?!\\2))*)\[/\\2\]([\r\n]*)\\1#siU', '\\1\\3\\4', $text);
			if ($newText === null || $newText == $text)
			{
				break;
			}
			$text = $newText;
		}
		while (true);

		// redo this as the color/size clean up may have exposed this
		do
		{
			$newText = preg_replace('#\[/(b|i|u|s|left|center|right|justify)\]([\r\n]*)\[\\1\]#i', '\\2', $text);
			if ($newText === null || $newText == $text)
			{
				break;
			}
			$text = $newText;
		}
		while (true);

		return \XF::cleanString($text);
	}

	public function renderTag(Tag $tag, array $state = [])
	{
		if ($tag->tagName() == 'br')
		{
			$output = new BbCode_Element('block', self::BR_SUBSTITUTE);
			$output->incrementTrailingLines();
			return $output;
		}

		$state = array_merge($state, $this->_setTagStates($tag, $state));

		if (!empty($state['hidden']))
		{
			// ignore all under this
			return new BbCode_Element('text', '');
		}

		$isPreFormatted = !empty($state['preFormatted']);

		$children = $this->renderChildren($tag, $state);

		if ($tag->isBlock() && !$isPreFormatted)
		{
			// ignore leading/trailing whitespace-only nodes on blocks
			$firstChild = reset($children);
			if ($firstChild && $firstChild->isWhiteSpace())
			{
				array_shift($children);
			}

			$lastChild = end($children);
			if ($lastChild && $lastChild->isWhiteSpace())
			{
				array_pop($children);
			}
		}

		$children = array_values($children); // need this to be contiguous
		$lastChild = count($children) - 1;
		$outputText = '';

		$output = new BbCode_Element($tag->isBlock() ? 'block' : 'inline');
		$previousTrailing = 0;
		$initialLeading = 0;

		for ($i = 0; $i <= $lastChild; $i++)
		{
			$child = $children[$i]; /* @var $child BbCode_Element */
			$previous = ($i > 0 ? $children[$i - 1] : false); /* @var $previous BbCode_Element */
			$next = ($i < $lastChild ? $children[$i + 1] : false); /* @var $next BbCode_Element */

			if ($child->isBr())
			{
				$previousTrailing++;
				continue;
			}

			if (!$isPreFormatted && $child->isWhiteSpace()
				&& $previous && $previous->isBlock()
				&& $next && $next->isBlock())
			{
				// whitespace node between 2 blocks - skip it
				continue;
			}

			$text = $child->text();
			if (!$isPreFormatted && $previousTrailing && $child->isText())
			{
				// follows a block
				$text = ltrim($text);
			}

			if ($outputText === '')
			{
				// no output so far, so push this up
				$initialLeading += $child->leadingLines();
			}
			else if ($child->leadingLines())
			{
				// this behaves like a block tag in terms of line spacing
				if ($previousTrailing && $child->leadingLines())
				{
					$previousTrailing -= 1; // a new block tag "merges" with the last line the previous
				}
				$previousTrailing += $child->leadingLines();

				if (!$isPreFormatted)
				{
					$outputText = strrev(preg_replace('/^( )+/', '', strrev($outputText)));
				}
			}

			if ($previousTrailing && $text !== '')
			{
				// covers previous trailing and my leading
				$outputText .= str_repeat("\n", $previousTrailing);
				$previousTrailing = 0;
			}

			$outputText .= $text;
			$previousTrailing += $child->trailingLines();
		}

		if ($tag->isReplaced())
		{
			// ignore any line breaks/brs within the tag if this is going to be replaced (the content won't be rendered)
			$previousTrailing = 0;
		}

		if ($output->isBlock() && !$isPreFormatted)
		{
			$outputText = trim($outputText);
		}

		if ($outputText !== '' || $tag->isAllowedEmpty())
		{
			// only prepare this tag if we actually have text or it's never going to have text
			$tagName = $tag->tagName();

			$handler = ($this->_handlers[$tagName] ?? false);

			$preCssOutput = $outputText;
			if ($tagName && (!$handler || empty($handler['skipCss'])))
			{
				$outputText = $this->renderCss($tag, $outputText);
			}

			if ($handler)
			{
				if (!empty($handler['filterCallback']))
				{
					$callback = $handler['filterCallback'];
					if (is_array($callback) && $callback[0] == '$this')
					{
						$callback[0] = $this;
					}
					$outputText = call_user_func($callback, $outputText, $tag, $preCssOutput);
				}
				else if (isset($handler['wrap']))
				{
					$outputText = sprintf($handler['wrap'], $outputText);
				}
			}

			$output->append($outputText);
		}

		if ($output->isBlock() && !$output->isEmpty())
		{
			// add an extra line break before/after if we have something to output
			// note that tags without could've already incremented these
			$output->incrementLeadingLines();
			$output->incrementTrailingLines();

			if ($initialLeading)
			{
				// merge 1 of the initial leading lines with this
				$initialLeading--;
			}

			if ($previousTrailing)
			{
				// merge 1 of the left over trailing lines with this
				$previousTrailing--;
			}
		}

		if ($initialLeading)
		{
			// push initial leading lines up
			$output->incrementLeadingLines($initialLeading);
		}

		if ($previousTrailing)
		{
			$output->incrementTrailingLines($previousTrailing);
		}

		if ($output->leadingLines() || $output->trailingLines())
		{
			//$output->setType('block');
		}

		return $output;
	}

	protected function _setTagStates(Tag $tag, array $existingStates)
	{
		$states = [];

		switch ($tag->tagName())
		{
			case 'pre':
				$states['preFormatted'] = true;
				break;

			case 'script':
			case 'title':
			case 'style':
			case 'embed':
			case 'object':
			case 'iframe':
				$states['hidden'] = true;
				break;
		}

		return $states;
	}

	public function renderChildren(Tag $tag, array $state)
	{
		$output = [];
		foreach ($tag->children() AS $child)
		{
			if ($child instanceof Tag)
			{
				$output[] = $this->renderTag($child, $state);
			}
			else if ($child instanceof Text)
			{
				$output[] = $this->renderText($child, $state);
			}
		}

		return $output;
	}

	public function renderText(Text $text, array $state)
	{
		$text = $text->text();
		if (empty($state['preFormatted']))
		{
			$text = preg_replace('/[\r\n\t ]+/', ' ', $text);
		}

		return new BbCode_Element('text', $text);
	}

	/**
	 * Renders the CSS for a given tag.
	 *
	 * @param Tag $tag
	 * @param string $stringOutput
	 *
	 * @return string BB code output
	 */
	public function renderCss(Tag $tag, $stringOutput)
	{
		$css = $tag->attribute('style');
		if ($css)
		{
			foreach ($css AS $cssRule => $cssValue)
			{
				if (strtolower($cssRule) == 'display' && strtolower($cssValue) == 'none')
				{
					return '';
				}

				if (!empty($this->_cssHandlers[$cssRule]))
				{
					$callback = $this->_cssHandlers[$cssRule];
					if (is_array($callback) && $callback[0] == '$this')
					{
						$callback[0] = $this;
					}
					$stringOutput = call_user_func($callback, $stringOutput, $cssValue, $tag);
				}
			}

			// images aligned on their own are done this way
			$alignRules = array_merge([
				'display' => '',
				'margin-left' => '',
				'margin-right' => '',
			], $css);
			if ($alignRules['display'] == 'block' && (!$tag->isVoid() || $stringOutput !== ''))
			{
				if ($alignRules['margin-left'] == 'auto' && $alignRules['margin-right'] == 'auto')
				{
					$stringOutput = '[CENTER]' . $stringOutput . '[/CENTER]';
				}
				else if ($alignRules['margin-left'] == 'auto' && substr($alignRules['margin-right'], 0, 1) == '0')
				{
					$stringOutput = '[RIGHT]' . $stringOutput . '[/RIGHT]';
				}
				else if (substr($alignRules['margin-left'], 0, 1) == '0' && $alignRules['margin-right'] == 'auto')
				{
					$stringOutput = '[LEFT]' . $stringOutput . '[/LEFT]';
				}
			}
		}

		$align = $tag->attribute('align');
		if ($align && (!$css || empty($css['text-align'])))
		{
			$stringOutput = $this->handleCssTextAlign($stringOutput, $align, $tag);
		}

		return $stringOutput;
	}

	public function convertUrlToAbsolute($url)
	{
		if (preg_match('#^(https?|ftp)://#i', $url))
		{
			return $url;
		}

		if (!$this->_options['baseUrl'])
		{
			return $url;
		}

		if ($url === '')
		{
			return $this->_options['baseUrl'];
		}

		preg_match(
			'#^(?P<protocolHost>(?P<protocol>https?|ftp)://[^/]+)(?P<path>.*)$#i',
			$this->_options['baseUrl'],
			$baseParts
		);
		if (!$baseParts)
		{
			return $url;
		}

		if (substr($url, 0, 2) == '//')
		{
			return $baseParts['protocol'] . ':' . $url;
		}

		if ($url[0] == '/')
		{
			return $baseParts['protocolHost'] . $url;
		}

		if (preg_match('#^((\.\./)+)#', $url, $upMatch))
		{
			$count = strlen($upMatch[1]) / strlen($upMatch[2]);

			for ($i = 1; $i <= $count; $i++)
			{
				$baseParts['path'] = dirname($baseParts['path']);
			}

			$url = substr($url, strlen($upMatch[0]));
		}

		$baseParts['path'] = str_replace('\\', '/', $baseParts['path']);

		if (substr($baseParts['path'], -1) != '/')
		{
			$baseParts['path'] .= '/';
		}
		if ($url[0] == '/')
		{
			// path has trailing slash
			$url = substr($url, 1);
		}

		return $baseParts['protocolHost'] . $baseParts['path'] . $url;
	}

	public function handleTagSpan($text, Tag $tag)
	{
		if ($tag->hasClass('smilie') && $tag->attribute('data-smilie'))
		{
			$smilies = \XF::app()->container('smilies');

			$smilieId = intval($tag->attribute('data-smilie'));
			$smilie = $smilies[$smilieId] ?? null;

			if ($smilie && !empty($smilie['emoji_shortname']))
			{
				// if we have a short name and its valid for this smilie, return that smilie text...
				$shortname = $tag->attribute('data-shortname');
				if ($shortname && in_array($shortname, $smilie['smilieText']))
				{
					return $shortname;
				}
				// ...else, we don't know if this text is actually valid for the smilie so keep the text as is
			}
			// ...otherwise the smilie shouldn't be in this format
		}

		return $text;
	}

	public function handleTagFont($text, Tag $tag)
	{
		$color = trim($tag->attribute('color'));
		if ($color)
		{
			$text = "[COLOR={$color}]{$text}[/COLOR]";
		}

		$size = trim($tag->attribute('size'));
		if ($size && preg_match('/^[0-9]+(px)?$/i', $size))
		{
			$text = "[SIZE={$size}]{$text}[/SIZE]";
		}

		$face = trim($tag->attribute('face'));
		if ($face)
		{
			$text = "[FONT={$face}]{$text}[/FONT]";
		}

		return $text;
	}

	/**
	 * Handles A tags. Can generate URL or EMAIL tags in BB code.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagA($text, Tag $tag)
	{
		$href = trim($tag->attribute('href'));
		if (!$href)
		{
			return $text;
		}

		if (preg_match('#^(data:|blob:|tel:|sms:|webkit-fake-url:|x-apple-data-detectors:)#i', $href))
		{
			return $text;
		}

		$userId = intval($tag->attribute('data-user-id'));
		if ($userId)
		{
			return "[USER={$userId}]{$text}[/USER]";
		}

		if (preg_match('/^mailto:(.+)$/i', $href, $match))
		{
			$target = $match[1];
			$type = 'EMAIL';
		}
		else
		{
			$target = $this->convertUrlToAbsolute($href);
			$type = 'URL';
		}

		if ($target == $text)
		{
			// look for part of a BB code at the end that may have been swallowed up
			if (preg_match('#\[/?([a-z0-9_-]+)$#i', $text, $match))
			{
				$append = $match[0];
				$text = substr($text, 0, -strlen($match[0]));
			}
			else
			{
				$append = '';
			}

			return "[$type]{$text}[/$type]$append";
		}
		else
		{
			return "[$type='$target']{$text}[/$type]";
		}
	}

	/**
	 * Handles IMG tags.
	 *
	 * @param string $text Child text of the tag (probably none)
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagImg($text, Tag $tag)
	{
		if (($tag->hasClass('smilie') || $tag->attribute('data-smilie')) && $tag->attribute('alt'))
		{
			// regular image smilie / emoji image
			$output = trim($tag->attribute('alt'));
		}
		else if ($this->tagIsAttachment($tag, $match))
		{
			$output = sprintf(
				'[ATTACH%1$s%2$s]%3$s[/ATTACH]',
				($match[1] == 'full' ? ' type="full"' : ''),
				$this->getAttachAttributes($tag),
				$match[2]
			);
		}
		else
		{
			$src = $tag->attribute('src');
			$proxyUrl = $tag->attribute('data-url');
			if ($proxyUrl)
			{
				$src = $proxyUrl;
			}

			$output = '';

			if (preg_match('#^(data:|blob:|webkit-fake-url:)#i', $src))
			{
				// data URI - ignore
			}
			else if ($src)
			{
				$smilies = \XF::app()->container('smilies');
				foreach ($smilies AS $smilie)
				{
					if ($src == $smilie['image_url'])
					{
						$output = reset($smilie['smilieText']);
						break;
					}
				}

				if (!$output)
				{
					$output =  '[IMG' . $this->getAttachAttributes($tag) . ']' . $this->convertUrlToAbsolute($src) . '[/IMG]';
				}
			}
		}

		return $this->renderCss($tag, $output);
	}

	/**
	 * Handles VIDEO tags.
	 *
	 * @param string $text Child text of the tag (probably none)
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagVideo($text, Tag $tag)
	{
		if ($this->tagIsAttachment($tag, $match))
		{
			$output = sprintf(
				'[ATTACH%1$s%2$s]%3$s[/ATTACH]',
				($match[1] == 'full' ? ' type="full"' : ''),
				$this->getAttachAttributes($tag),
				$match[2]
			);
		}
		else
		{
			$output = '';
		}

		return $this->renderCss($tag, $output);
	}

	/**
	 * Handles AUDIO tags.
	 *
	 * @param string $text Child text of the tag (probably none)
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagAudio($text, Tag $tag)
	{
		if ($this->tagIsAttachment($tag, $match))
		{
			$output = sprintf(
				'[ATTACH%1$s%2$s]%3$s[/ATTACH]',
				($match[1] == 'full' ? ' type="full"' : ''),
				$this->getAttachAttributes($tag),
				$match[2]
			);
		}
		else
		{
			$output = '';
		}

		return $this->renderCss($tag, $output);
	}

	protected function tagIsAttachment(Tag $tag, &$match)
	{
		return $tag->attribute('data-attachment')
			&& preg_match('#^(thumb|full):(\d+)$#', $tag->attribute('data-attachment'), $match);
	}

	public function getAttachAttributes(Tag $tag)
	{
		$attributes = $this->getAttachAlignAttribute($tag)
			. $this->getAttachSizeAttributes($tag);

		if ($tag->tagName() == 'img')
		{
			$attributes .= $this->getAttachAltAttribute($tag);
		}

		return $attributes;
	}

	protected function getAttachAlignAttribute(Tag $tag)
	{
		if ($tag->hasClass('fr-fir') || $tag->hasClass('fr-fvr'))
		{
			return ' align="right"';
		}
		else if ($tag->hasClass('fr-fil') || $tag->hasClass('fr-fvl'))
		{
			return ' align="left"';
		}
		else
		{
			return '';
		}
	}

	protected function getAttachSizeAttributes(Tag $tag)
	{
		$attr = '';

		if ($style = $tag->attribute('style'))
		{
			if (isset($style['width']))
			{
				if (preg_match('/^(?P<width>[\d\.]+(?:px|%))$/i', $style['width'], $match))
				{
					$attr .= " width=\"{$match['width']}\"";
				}
			}
			if (isset($style['height']))
			{
				if (preg_match('/^(?P<height>[\d\.]+(?:px|%))$/i', $style['height'], $match))
				{
					$attr .= " height=\"{$match['height']}\"";
				}
			}
		}
		if ($size = $tag->attribute('data-size'))
		{
			$attr .= " size=\"{$size}\"";
		}

		return $attr;
	}

	protected function getAttachAltAttribute(Tag $tag)
	{
		if ($_alt = $tag->attribute('alt'))
		{
			return ' alt="' . str_replace('"', '', $tag->attribute('alt')) . '"';
		}
		else
		{
			return '';
		}
	}

	protected function handleListTag($listFormatter, $text, Tag $tag)
	{
		if (!strlen($text))
		{
			// no children, just do a linebreak
			return "\n";
		}

		$childList = 0;
		$childOtherTag = 0;
		$childText = 0;

		foreach ($tag->children() AS $child)
		{
			if ($child instanceof Tag)
			{
				if ($child->tagName() == 'ol' || $child->tagName() == 'ul')
				{
					$childList++;
				}
				else
				{
					$childOtherTag++;
				}
			}
			else if ($child instanceof Text)
			{
				if (strlen(trim($child->text())) > 0)
				{
					$childText++;
				}
			}
		}

		if ($childList && !$childOtherTag && !$childText)
		{
			// just a list like <ul><ul><li>...</ul></ul>. This is how Chrome implements indent
			$output = "[INDENT]{$text}[/INDENT]";
		}
		else
		{
			$output = sprintf($listFormatter, $text);
		}

		return $this->renderCss($tag, $output);
	}

	/**
	 * Handles UL tags.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagUl($text, Tag $tag)
	{
		return $this->handleListTag("[LIST]\n%s\n[/LIST]", $text, $tag);
	}

	/**
	 * Handles OL tags.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagOl($text, Tag $tag)
	{
		return $this->handleListTag("[LIST=1]\n%s\n[/LIST]", $text, $tag);
	}

	/**
	 * Handles LI tags.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagLi($text, Tag $tag)
	{
		$parent = $tag->parent();
		if ($parent && !in_array($parent->tagName(), ['ol', 'ul']))
		{
			if (trim($text) === '')
			{
				return '';
			}
			else
			{
				return '[LIST][*]' . $text . '[/LIST]';
			}
		}
		else
		{
			if (substr($text, -1) == self::BR_SUBSTITUTE)
			{
				// has a trailiing br. we need to add an extra line to make it really count
				$text .= "\n";
			}
			return '[*]' . $text;
		}
	}

	/**
	 * Handles inline code and code tags.
	 *
	 * @param string $text
	 * @param Tag $tag
	 *
	 * @return string
	 */
	public function handleTagCode($text, Tag $tag)
	{
		if (preg_match('/[\r\n]/', $text))
		{
			return '[CODE]' . $text . '[/CODE]';
		}
		else
		{
			return '[ICODE]' . $text . '[/ICODE]';
		}
	}

	public function handleTagBlockquote($text, Tag $tag)
	{
		if (!strlen($text))
		{
			return "\n";
		}

		$name = $tag->attribute('data-quote');
		if ($name)
		{
			$parts = [$name];

			$source = $tag->attribute('data-source');
			if ($source)
			{
				$parts[] = $source;

				$extra = $tag->attribute('data-attributes');
				if ($extra)
				{
					$parts[] = $extra;
				}
			}

			$tagOption = str_replace('"', '', implode(', ', $parts));
			$tagOpen = "[QUOTE=\"{$tagOption}\"]";
		}
		else
		{
			$tagOpen = '[QUOTE]';
		}

		$output = "{$tagOpen}\n{$text}\n[/QUOTE]";

		return $output;
	}

	/**
	 * Handles table tags. This is mostly done as a callback as we need to apply any CSS changes around this tag.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagTable($text, Tag $tag)
	{
		$width = $tag->attributes()['style']['width'] ?? null;

		if ($width)
		{
			$output = "[TABLE width=\"$width\"]\n{$text}\n[/TABLE]";
		}
		else
		{
			$output = "[TABLE]\n{$text}\n[/TABLE]";
		}

		return $this->renderCss($tag, $output);
	}

	public function handleTagCell($text, Tag $tag)
	{
		$tagName = $tag->tagName();
		$width = $tag->attributes()['style']['width'] ?? null;

		if ($width)
		{
			$output = "[$tagName width=\"$width\"]{$text}[/$tagName]";
		}
		else
		{
			$output = "[$tagName]{$text}[/$tagName]";
		}

		return $this->renderCss($tag, $output);
	}

	/**
	 * Handles heading tags.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagH($text, Tag $tag)
	{
		switch ($tag->tagName())
		{
			case 'h1':
			case 'h2':
				$type = 1;
				break;

			case 'h3':
				$type = 2;
				break;

			default: //h4-h6
				$type = 3;
		}

		return '[HEADING=' . $type . ']' . $text . "[/HEADING]";
	}

	/**
	 * Handles hr tags.
	 *
	 * @param string $text Child text of the tag
	 * @param Tag $tag HTML tag triggering call
	 *
	 * @return string
	 */
	public function handleTagHr($text, Tag $tag)
	{
		return '[HR][/HR]';
	}

	/**
	 * Handles CSS (text) color rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssColor($text, $color)
	{
		// workaround for [COLOR=hsl(var(--xf-editorFocusColor))] Froala bug
		if (strpos($color, 'var(') !== false || strpos($color, '--xf-') !== false)
		{
			return $text;
		}
		return "[COLOR=$color]{$text}[/COLOR]";
	}

	/**
	 * Handles CSS float rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssFloat($text, $alignment)
	{
		switch (strtolower($alignment))
		{
			case 'left':
			case 'right':
				$alignmentUpper = strtoupper($alignment);
				return "[$alignmentUpper]{$text}[/$alignmentUpper]";

			default:
				return $text;
		}
	}

	/**
	 * Handles CSS font-family rules. The first font is used.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssFontFamily($text, $cssValue)
	{
		[$fontFamily] = explode(',', $cssValue);
		if (preg_match('/^(\'|")(.*)\\1$/', $fontFamily, $match))
		{
			$fontFamily = $match[2];
		}

		if ($fontFamily && preg_match('/^[a-z0-9 \-]+$/i', $fontFamily))
		{
			return "[FONT=$fontFamily]{$text}[/FONT]";
		}
		else
		{
			return $text;
		}
	}

	/**
	 * Handles CSS font-size rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssFontSize($text, $fontSize)
	{
		switch (strtolower($fontSize))
		{
			case 'xx-small':
			case '9px':
				$fontSize = 1;
				break;

			case 'x-small':
			case '10px':
				$fontSize = 2;
				break;

			case 'small':
			case '12px':
				$fontSize = 3;
				break;

			case 'medium':
			case '15px':
			case '100%':
				$fontSize = 4;
				break;

			case 'large':
			case '18px':
				$fontSize = 5;
				break;

			case 'x-large':
			case '22px':
				$fontSize = 6;
				break;

			case 'xx-large':
			case '26px':
				$fontSize = 7;
				break;

			default:
				if (!preg_match('/^[0-9]+(px)?$/i', $fontSize))
				{
					$fontSize = 0;
				}
		}

		if ($fontSize)
		{
			return "[SIZE=$fontSize]{$text}[/SIZE]";
		}
		else
		{
			return $text;
		}
	}

	/**
	 * Handles CSS font-style rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssFontStyle($text, $fontStyle)
	{
		switch (strtolower($fontStyle))
		{
			case 'italic':
			case 'oblique':
				return '[I]' . $text . '[/I]';

			default:
				return $text;
		}
	}

	/**
	 * Handles CSS font-weight rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssFontWeight($text, $fontWeight)
	{
		switch (strtolower($fontWeight))
		{
			case 'bold':
			case 'bolder':
			case '700':
			case '800':
			case '900':
				return '[B]' . $text . '[/B]';

			default:
				return $text;
		}
	}

	/**
	 * Handles CSS padding-left/margin-left rules to represent LTR indent.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $amount Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssIndentLeft($text, $amount)
	{
		$language = \XF::language();
		if ($language['text_direction'] == 'RTL')
		{
			return $text;
		}

		if (preg_match('/^(\d+)px$/i', $amount, $match))
		{
			$depth = floor($match[1] / 20); // editor puts in 20px
			if ($depth)
			{
				$open = ($depth > 1 ? '[INDENT=' . $depth . ']' : '[INDENT]');

				return $open . $text . '[/INDENT]';
			}
		}

		return $text;
	}

	/**
	 * Handles CSS padding-right/margin-right rules to represent RTL indent.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $amount Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssIndentRight($text, $amount)
	{
		$language = \XF::language();
		if ($language['text_direction'] != 'RTL')
		{
			return $text;
		}

		if (preg_match('/^(\d+)px$/i', $amount, $match))
		{
			$depth = floor($match[1] / 20); // editor puts in 20px
			if ($depth)
			{
				$open = ($depth > 1 ? '[INDENT=' . $depth . ']' : '[INDENT]');

				return $open . $text . '[/INDENT]';
			}
		}

		return $text;
	}

	/**
	 * Handles CSS text-align rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 * @param Tag $tag
	 *
	 * @return string
	 */
	public function handleCssTextAlign($text, $alignment, Tag $tag)
	{
		switch (strtolower($alignment))
		{
			case 'left':
			case 'center':
			case 'right':
			case 'justify':
				$alignmentUpper = strtoupper($alignment);

				if (!$tag->parent() || !$tag->parent()->tagName())
				{
					$language = \XF::language();
					if (
						($language['text_direction'] == 'RTL' && $alignmentUpper == 'RIGHT')
						|| ($language['text_direction'] != 'RTL' && $alignmentUpper == 'LEFT')
					)
					{
						return $text;
					}
				}

				return "[$alignmentUpper]{$text}[/$alignmentUpper]";

			default:
				return $text;
		}
	}

	/**
	 * Handles CSS text-decoration rules.
	 *
	 * @param string $text Child text of the tag with the CSS
	 * @param string $alignment Value of the CSS rule
	 *
	 * @return string
	 */
	public function handleCssTextDecoration($text, $decoration)
	{
		switch (strtolower($decoration))
		{
			case 'underline':
				return "[U]{$text}[/U]";

			case 'line-through':
				return "[S]{$text}[/S]";

			default:
				return $text;
		}
	}
}

class BbCode_Element
{
	protected $_type = '';
	protected $_text = '';
	protected $_isWhiteSpace = null;

	protected $_modifiers = [];
	protected $_leadingLines = 0;
	protected $_trailingLines = 0;

	public function __construct($type, $text = '')
	{
		$this->setType($type);
		$this->setText($text);
	}

	public function type()
	{
		return $this->_type;
	}

	public function text()
	{
		return $this->_text;
	}

	public function append($text)
	{
		$this->_text .= $text;
		$this->_setIsWhiteSpace();
	}

	public function setText($text)
	{
		$this->_text = $text;
		$this->_setIsWhiteSpace();
	}

	protected function _setIsWhiteSpace()
	{
		$this->_isWhiteSpace = (strlen(trim($this->_text)) == 0);
	}

	public function setType($type)
	{
		$this->_type = $type;
	}

	public function setModifier($key, $value = true)
	{
		$this->_modifiers[$key] = $value;
	}

	public function unsetModifier($key)
	{
		unset($this->_modifiers[$key]);
	}

	public function getModifier($key)
	{
		return ($this->_modifiers[$key] ?? null);
	}

	public function incrementModifier($key, $offset = 1)
	{
		if (!isset($this->_modifiers[$key]))
		{
			$this->_modifiers[$key] = $offset;
		}
		else
		{
			$this->_modifiers[$key] += $offset;
		}
	}

	public function decrementModifier($key, $offset = 1)
	{
		if (isset($this->_modifiers[$key]))
		{
			$this->_modifiers[$key] -= $offset;
			if ($this->_modifiers[$key] <= 0)
			{
				$this->unsetModifier($key);
			}
		}
	}

	public function leadingLines()
	{
		return $this->_leadingLines;
	}

	public function incrementLeadingLines($offset = 1)
	{
		$this->_leadingLines += $offset;
	}

	public function decrementLeadingLines($offset = 1)
	{
		$this->_leadingLines = max(0, $this->_leadingLines - $offset);
	}

	public function trailingLines()
	{
		return $this->_trailingLines;
	}

	public function incrementTrailingLines($offset = 1)
	{
		$this->_trailingLines += $offset;
	}

	public function decrementTrailingLines($offset = 1)
	{
		$this->_trailingLines = max(0, $this->_trailingLines - $offset);
	}

	public function isBlock()
	{
		return ($this->_type == 'block');
	}

	public function isBr()
	{
		return ($this->_type == 'br');
	}

	public function isInline()
	{
		return !$this->isBlock();
	}

	public function isText()
	{
		return ($this->_type == 'text');
	}

	public function isEmpty()
	{
		return (strlen(trim($this->_text)) == 0);
	}

	public function isWhiteSpace()
	{
		return ($this->_isWhiteSpace && $this->isText());
	}
}
