<?php

/**
* @package   s9e\TextFormatter
* @copyright Copyright (c) 2010-2022 The s9e authors
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
*/
namespace s9e\TextFormatter\Configurator\Collections;

use ArrayAccess;
use BadMethodCallException;
use InvalidArgumentException;
use RuntimeException;
use s9e\TextFormatter\Configurator\ConfigProvider;
use s9e\TextFormatter\Configurator\JavaScript\Dictionary;
use s9e\TextFormatter\Configurator\Validators\TagName;
use s9e\TextFormatter\Parser;

/**
* @method void allowChild(string $tagName)
* @method void allowDescendant(string $tagName)
* @method void autoClose(bool $bool = true)
* @method void autoReopen(bool $bool = true)
* @method void breakParagraph(bool $bool = true)
* @method void closeAncestor(string $tagName)
* @method void closeParent(string $tagName)
* @method void createChild(string $tagName)
* @method void createParagraphs(bool $bool = true)
* @method void denyChild(string $tagName)
* @method void denyDescendant(string $tagName)
* @method void disableAutoLineBreaks(bool $bool = true)
* @method void enableAutoLineBreaks(bool $bool = true)
* @method void fosterParent(string $tagName)
* @method void ignoreSurroundingWhitespace(bool $bool = true)
* @method void ignoreTags(bool $bool = true)
* @method void ignoreText(bool $bool = true)
* @method void isTransparent(bool $bool = true)
* @method void preventLineBreaks(bool $bool = true)
* @method void requireParent(string $tagName)
* @method void requireAncestor(string $tagName)
* @method void suspendAutoLineBreaks(bool $bool = true)
* @method void trimFirstLine(bool $bool = true)
* @see /docs/Rules.md
*/
class Ruleset extends Collection implements ArrayAccess, ConfigProvider
{
	/**
	* @var array Supported rules and the method used to add them
	*/
	protected $rules = [
		'allowChild'                  => 'addTargetedRule',
		'allowDescendant'             => 'addTargetedRule',
		'autoClose'                   => 'addBooleanRule',
		'autoReopen'                  => 'addBooleanRule',
		'breakParagraph'              => 'addBooleanRule',
		'closeAncestor'               => 'addTargetedRule',
		'closeParent'                 => 'addTargetedRule',
		'createChild'                 => 'addTargetedRule',
		'createParagraphs'            => 'addBooleanRule',
		'denyChild'                   => 'addTargetedRule',
		'denyDescendant'              => 'addTargetedRule',
		'disableAutoLineBreaks'       => 'addBooleanRule',
		'enableAutoLineBreaks'        => 'addBooleanRule',
		'fosterParent'                => 'addTargetedRule',
		'ignoreSurroundingWhitespace' => 'addBooleanRule',
		'ignoreTags'                  => 'addBooleanRule',
		'ignoreText'                  => 'addBooleanRule',
		'isTransparent'               => 'addBooleanRule',
		'preventLineBreaks'           => 'addBooleanRule',
		'requireParent'               => 'addTargetedRule',
		'requireAncestor'             => 'addTargetedRule',
		'suspendAutoLineBreaks'       => 'addBooleanRule',
		'trimFirstLine'               => 'addBooleanRule'
	];

	/**
	* Add a rule to this set
	*
	* @param  string $methodName Rule name
	* @param  array  $args       Arguments used to add given rule
	* @return self
	*/
	public function __call($methodName, array $args)
	{
		if (!isset($this->rules[$methodName]))
		{
			throw new BadMethodCallException("Undefined method '" . $methodName . "'");
		}

		array_unshift($args, $methodName);
		call_user_func_array([$this, $this->rules[$methodName]], $args);

		return $this;
	}

	//==========================================================================
	// ArrayAccess methods
	//==========================================================================

	/**
	* Test whether a rule category exists
	*
	* @param  string $k Rule name, e.g. "allowChild" or "isTransparent"
	*/
	public function offsetExists($k): bool
	{
		return isset($this->items[$k]);
	}

	/**
	* Return the content of a rule category
	*
	* @param  string $k Rule name, e.g. "allowChild" or "isTransparent"
	* @return mixed
	*/
	#[\ReturnTypeWillChange]
	public function offsetGet($k)
	{
		return $this->items[$k];
	}

	/**
	* Not supported
	*/
	public function offsetSet($k, $v): void
	{
		throw new RuntimeException('Not supported');
	}

	/**
	* Clear a subset of the rules
	*
	* @see clear()
	*
	* @param  string $k Rule name, e.g. "allowChild" or "isTransparent"
	*/
	public function offsetUnset($k): void
	{
		$this->remove($k);
	}

	//==========================================================================
	// Generic methods
	//==========================================================================

	/**
	* {@inheritdoc}
	*/
	public function asConfig()
	{
		$config = $this->items;

		// Remove rules that are not needed at parsing time. All of those are resolved when building
		// the allowed bitfields
		unset($config['allowChild']);
		unset($config['allowDescendant']);
		unset($config['denyChild']);
		unset($config['denyDescendant']);
		unset($config['requireParent']);

		// Pack boolean rules into a bitfield
		$bitValues = [
			'autoClose'                   => Parser::RULE_AUTO_CLOSE,
			'autoReopen'                  => Parser::RULE_AUTO_REOPEN,
			'breakParagraph'              => Parser::RULE_BREAK_PARAGRAPH,
			'createParagraphs'            => Parser::RULE_CREATE_PARAGRAPHS,
			'disableAutoLineBreaks'       => Parser::RULE_DISABLE_AUTO_BR,
			'enableAutoLineBreaks'        => Parser::RULE_ENABLE_AUTO_BR,
			'ignoreSurroundingWhitespace' => Parser::RULE_IGNORE_WHITESPACE,
			'ignoreTags'                  => Parser::RULE_IGNORE_TAGS,
			'ignoreText'                  => Parser::RULE_IGNORE_TEXT,
			'isTransparent'               => Parser::RULE_IS_TRANSPARENT,
			'preventLineBreaks'           => Parser::RULE_PREVENT_BR,
			'suspendAutoLineBreaks'       => Parser::RULE_SUSPEND_AUTO_BR,
			'trimFirstLine'               => Parser::RULE_TRIM_FIRST_LINE
		];

		$bitfield = 0;
		foreach ($bitValues as $ruleName => $bitValue)
		{
			if (!empty($config[$ruleName]))
			{
				$bitfield |= $bitValue;
			}

			unset($config[$ruleName]);
		}

		// In order to speed up lookups, we use the tag names as keys
		foreach (['closeAncestor', 'closeParent', 'fosterParent'] as $ruleName)
		{
			if (isset($config[$ruleName]))
			{
				$targets = array_fill_keys($config[$ruleName], 1);
				$config[$ruleName] = new Dictionary($targets);
			}
		}

		// Add the bitfield to the config
		$config['flags'] = $bitfield;

		return $config;
	}

	/**
	* Merge a set of rules into this collection
	*
	* @param array|Ruleset $rules     2D array of rule definitions, or instance of Ruleset
	* @param bool          $overwrite Whether to overwrite scalar rules (e.g. boolean rules)
	*/
	public function merge($rules, $overwrite = true)
	{
		if (!is_array($rules)
		 && !($rules instanceof self))
		{
			throw new InvalidArgumentException('merge() expects an array or an instance of Ruleset');
		}

		foreach ($rules as $action => $value)
		{
			if (is_array($value))
			{
				foreach ($value as $tagName)
				{
					$this->$action($tagName);
				}
			}
			elseif ($overwrite || !isset($this->items[$action]))
			{
				$this->$action($value);
			}
		}
	}

	/**
	* Remove a specific rule, or all the rules of a given type
	*
	* @param  string $type    Type of rules to clear
	* @param  string $tagName Name of the target tag, or none to remove all rules of given type
	* @return void
	*/
	public function remove($type, $tagName = null)
	{
		if (preg_match('(^default(?:Child|Descendant)Rule)', $type))
		{
			throw new InvalidArgumentException('Cannot remove ' . $type);
		}

		if (isset($tagName))
		{
			$tagName = TagName::normalize($tagName);

			if (isset($this->items[$type]))
			{
				// Compute the difference between current list and our one tag name
				$this->items[$type] = array_diff(
					$this->items[$type],
					[$tagName]
				);

				if (empty($this->items[$type]))
				{
					// If the list is now empty, keep it neat and unset it
					unset($this->items[$type]);
				}
				else
				{
					// If the list still have names, keep it neat and rearrange keys
					$this->items[$type] = array_values($this->items[$type]);
				}
			}
		}
		else
		{
			unset($this->items[$type]);
		}
	}

	//==========================================================================
	// Rules
	//==========================================================================

	/**
	* Add a boolean rule
	*
	* @param  string $ruleName Name of the rule
	* @param  bool   $bool     Whether to enable or disable the rule
	* @return self
	*/
	protected function addBooleanRule($ruleName, $bool = true)
	{
		if (!is_bool($bool))
		{
			throw new InvalidArgumentException($ruleName . '() expects a boolean');
		}

		$this->items[$ruleName] = $bool;
	}

	/**
	* Add a targeted rule
	*
	* @param  string $ruleName Name of the rule
	* @param  string $tagName  Name of the target tag
	* @return self
	*/
	protected function addTargetedRule($ruleName, $tagName)
	{
		$this->items[$ruleName][] = TagName::normalize($tagName);
	}
}