<?php

namespace XF\Html;

use function in_array;

/**
 * Represents an individual tag within an HTML tree.
 */
class Tag
{
	/**
	 * Name of the tag (lower case).
	 *
	 * @var string
	 */
	protected $_tagName = '';

	/**
	 * Key-value pairs of attributes for the tag
	 *
	 * @var array
	 */
	protected $_attributes = [];

	/**
	 * Parent tag object.
	 *
	 * @var Tag|null Null for root tag
	 */
	protected $_parent = null;

	/**
	 * List of child tags and text.
	 *
	 * @var array Values are Tag or Text elements
	 */
	protected $_children = [];

	/**
	 * List of tags that are considered to be block tags.
	 *
	 * @var array
	 */
	protected $_blockTags = [
		'address', 'article', 'aside', 'blockquote', 'dd', 'details',
		'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
		'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
		'header', 'hgroup', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
		'section', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'ul',
		// note that "" is not here
	];

	protected $_voidTags = [
		'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'wbr',
	];

	protected $_replacedTags = [
		'audio', 'video',
	];

	/**
	 * Constructor.
	 *
	 * @param string $tagName
	 * @param array $attributes
	 * @param Tag $parent
	 */
	public function __construct($tagName, array $attributes = [], ?Tag $parent = null)
	{
		$this->_tagName = strtolower($tagName);
		$this->_attributes = $attributes;
		$this->_parent = $parent;
	}

	/**
	 * Appends text to the tag. If the last child is text, it will be added
	 * to that child; otherwise, a new child will be created.
	 *
	 * @param string $text
	 */
	public function appendText($text)
	{
		if ($this->isVoid())
		{
			throw new \LogicException('Void tag ' . htmlspecialchars($this->_tagName) . ' cannot have children');
		}

		if ($this->_children)
		{
			$keys = array_keys($this->_children);
			$lastKey = end($keys);
			if ($this->_children[$lastKey] instanceof Text)
			{
				$this->_children[$lastKey]->addText($text);
				return;
			}
		}

		$this->_children[] = new Text($text, $this);
	}

	/**
	 * Adds a new child tag.
	 *
	 * @param string $tagName
	 * @param array $attributes
	 *
	 * @return Tag New child tag
	 */
	public function addChildTag($tagName, array $attributes = [])
	{
		if ($this->isVoid())
		{
			throw new \LogicException('Void tag ' . htmlspecialchars($this->_tagName) . ' cannot have children');
		}

		if (($tagName == 'li' || $tagName == 'p') && $tagName == $this->_tagName)
		{
			// can't child to this tag, should be a sibling
			$parent = $this->parent();
			if ($parent)
			{
				return $parent->addChildTag($tagName, $attributes);
			}
		}

		$child = new Tag($tagName, $attributes, $this);
		$this->_children[] = $child;

		return $child;
	}

	/**
	 * Closes the given tag. This generally does not require modifying the tag tree,
	 * unless invalid nesting occurred.
	 *
	 * @param string $tagName
	 *
	 * @return Tag The new "parent" tag that should be used by the parser
	 */
	public function closeTag($tagName)
	{
		$tagName = strtolower($tagName);
		if ($tagName == $this->_tagName || $this->isVoid())
		{
			return $this->_parent;
		}
		else
		{
			$stack = [];
			for ($tag = $this; $tag && $tag->tagName() != $tagName; $tag = $tag->parent())
			{
				$stack[] = $tag;
			}

			if ($tag)
			{
				$newParent = $tag->closeTag($tagName);
				while ($createTag = array_pop($stack))
				{
					$newParent = $newParent->addChildTag($createTag->tagName(), $createTag->attributes());
				}

				return $newParent;
			}
			else
			{
				// tag not found, ignore it
				return $this->_parent;
			}
		}
	}

	/**
	 * Gets the tag name.
	 *
	 * @return string
	 */
	public function tagName()
	{
		return $this->_tagName;
	}

	/**
	 * Gets the attributes.
	 *
	 * @return array
	 */
	public function attributes()
	{
		return $this->_attributes;
	}

	/**
	 * Gets the named attribute.
	 *
	 * @param string $attribute
	 *
	 * @return mixed|false
	 */
	public function attribute($attribute)
	{
		return ($this->_attributes[$attribute] ?? false);
	}

	public function hasClass($findClass)
	{
		$class = $this->attribute('class');
		if ($class)
		{
			return strpos(" $class ", " $findClass ") !== false;
		}
		else
		{
			return false;
		}
	}

	/**
	 * Gets the parent tag.
	 *
	 * @return Tag|null
	 */
	public function parent()
	{
		return $this->_parent;
	}

	/**
	 * Sets the parent tag. This does not check for circular references!
	 *
	 * @param Tag $parent
	 */
	public function setParent(Tag $parent)
	{
		$this->_parent = $parent;
	}

	/**
	 * Gets the child tags and text.
	 *
	 * @return array
	 */
	public function children()
	{
		return $this->_children;
	}

	/**
	 * Copies this tag. Does not copy any children tags or this tag's parent. The
	 * parent will need to be set manually later.
	 *
	 * @return Tag
	 */
	public function copy()
	{
		return new Tag($this->_tagName, $this->_attributes);
	}

	/**
	 * Determines if the tag has renderable content within.
	 *
	 * @return boolean
	 */
	public function isEmpty()
	{
		switch ($this->_tagName)
		{
			case 'img':
			case 'br':
				return false;
		}

		foreach ($this->children() AS $child)
		{
			if ($child instanceof Tag)
			{
				if (!$child->isEmpty())
				{
					return false;
				}
			}
			else if ($child instanceof Text)
			{
				if (trim($child->text()) !== '')
				{
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Determines if this tag is a block-level tag.
	 *
	 * @return boolean
	 */
	public function isBlock()
	{
		return in_array($this->_tagName, $this->_blockTags);
	}

	/**
	 * Determines if this tag is a void tag. Void tags can't have children.
	 *
	 * @return boolean
	 */
	public function isVoid()
	{
		return in_array($this->_tagName, $this->_voidTags);
	}

	/**
	 * Determines if this tag is valid even if it's empty. Automatically includes all void tags.
	 * Should only be used for tags that render content without children (such as a video tag).
	 *
	 * @return bool
	 */
	public function isAllowedEmpty()
	{
		return $this->isVoid() || $this->isReplaced();
	}

	/**
	 * Determines if the tag's output is normally replaced (when rendered by the browser). These
	 * tags can optionally have child tags/output that aren't normally displayed unless there's
	 * a reason. Normally you wouldn't want to count line breaks within these tags, for example.
	 *
	 * @return bool
	 */
	public function isReplaced()
	{
		return in_array($this->_tagName, $this->_replacedTags);
	}
}
