<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/

namespace phpbb\event;

/**
* Class php_exporter
* Crawls through a list of files and grabs all php-events
*/
class php_exporter
{
	/** @var string Path where we look for files*/
	protected $path;

	/** @var string phpBB Root Path */
	protected $root_path;

	/** @var string The minimum version for the events to return */
	protected $min_version;

	/** @var string The maximum version for the events to return */
	protected $max_version;

	/** @var string */
	protected $current_file;

	/** @var string */
	protected $current_event;

	/** @var int */
	protected $current_event_line;

	/** @var array */
	protected $events;

	/** @var array */
	protected $file_lines;

	/**
	* @param string $phpbb_root_path
	* @param mixed $extension	String 'vendor/ext' to filter, null for phpBB core
	* @param string $min_version
	* @param string $max_version
	*/
	public function __construct($phpbb_root_path, $extension = null, $min_version = null, $max_version = null)
	{
		$this->root_path = $phpbb_root_path;
		$this->path = $phpbb_root_path;
		$this->events = $this->file_lines = array();
		$this->current_file = $this->current_event = '';
		$this->current_event_line = 0;
		$this->min_version = $min_version;
		$this->max_version = $max_version;

		$this->path = $this->root_path;
		if ($extension)
		{
			$this->path .= 'ext/' . $extension . '/';
		}
	}

	/**
	* Get the list of all events
	*
	* @return array		Array with events: name => details
	*/
	public function get_events()
	{
		return $this->events;
	}

	/**
	* Set current event data
	*
	* @param string	$name	Name of the current event (used for error messages)
	* @param int	$line	Line where the current event is placed in
	* @return null
	*/
	public function set_current_event($name, $line)
	{
		$this->current_event = $name;
		$this->current_event_line = $line;
	}

	/**
	* Set the content of this file
	*
	* @param array $content		Array with the lines of the file
	* @return null
	*/
	public function set_content($content)
	{
		$this->file_lines = $content;
	}

	/**
	* Crawl the phpBB/ directory for php events
	* @return int	The number of events found
	*/
	public function crawl_phpbb_directory_php()
	{
		$files = $this->get_recursive_file_list();
		$this->events = array();
		foreach ($files as $file)
		{
			$this->crawl_php_file($file);
		}
		ksort($this->events);

		return count($this->events);
	}

	/**
	* Returns a list of files in $dir
	*
	* @return	array	List of files (including the path)
	*/
	public function get_recursive_file_list()
	{
		try
		{
			$iterator = new \RecursiveIteratorIterator(
				new \phpbb\event\recursive_event_filter_iterator(
					new \RecursiveDirectoryIterator(
						$this->path,
						\FilesystemIterator::SKIP_DOTS
					),
					$this->path
				),
				\RecursiveIteratorIterator::LEAVES_ONLY
			);
		}
		catch (\Exception $e)
		{
			return array();
		}

		$files = array();
		foreach ($iterator as $file_info)
		{
			/** @var \RecursiveDirectoryIterator $file_info */
			$relative_path = $iterator->getInnerIterator()->getSubPathname();
			$files[] = str_replace(DIRECTORY_SEPARATOR, '/', $relative_path);
		}

		return $files;
	}

	/**
	* Format the php events as a wiki table
	*
	* @param string $action
	* @return string
	* @deprecated 3.3.5-RC1 (To be removed: 4.0.0-a1)
	*/
	public function export_events_for_wiki($action = '')
	{
		if ($action === 'diff')
		{
			$wiki_page = '=== PHP Events (Hook Locations) ===' . "\n";
		}
		else
		{
			$wiki_page = '= PHP Events (Hook Locations) =' . "\n";
		}
		$wiki_page .= '{| class="sortable zebra" cellspacing="0" cellpadding="5"' . "\n";
		$wiki_page .= '! Identifier !! Placement !! Arguments !! Added in Release !! Explanation' . "\n";
		foreach ($this->events as $event)
		{
			$wiki_page .= '|- id="' . $event['event'] . '"' . "\n";
			$wiki_page .= '| [[#' . $event['event'] . '|' . $event['event'] . ']] || ' . $event['file'] . ' || ' . implode(', ', $event['arguments']) . ' || ' . $event['since'] . ' || ' . $event['description'] . "\n";
		}
		$wiki_page .= '|}' . "\n";

		return $wiki_page;
	}

	/**
	 * Format the PHP events as a rst table
	 *
	 * @param string $action
	 * @return string
	 */
	public function export_events_for_rst(string $action = ''): string
	{
		$rst_exporter = new rst_exporter();
		$rst_exporter->add_section_header($action === 'diff' ? 'h3' : 'h2', 'PHP Events');
		$rst_exporter->set_columns([
			'event'			=> 'Identifier',
			'file'			=> 'Placement',
			'arguments'		=> 'Arguments',
			'since'			=> 'Added in Release',
			'description'	=> 'Explanation',
		]);
		$rst_exporter->generate_events_table($this->events);

		return $rst_exporter->get_rst_output();
	}

	/**
	 * Format the PHP events as a BBCode list
	 *
	 * @param string $action
	 * @return string
	 */
	public function export_events_for_bbcode(string $action = ''): string
	{
		if ($action === 'diff')
		{
			$bbcode_text = '[size=150]PHP Events[/size]' . "\n";
		}
		else
		{
			$bbcode_text = '[size=200]PHP Events[/size]' . "\n";
		}

		foreach ($this->events as $event)
		{
			$bbcode_text .= "[list]\n";
			$bbcode_text .= "[*][b]{$event['event']}[/b]\n";
			$bbcode_text .= "Placement: {$event['file']}\n";
			$bbcode_text .= 'Arguments: ' . implode(', ', $event['arguments']) . "\n";
			$bbcode_text .= "Added in Release: {$event['since']}\n";
			$bbcode_text .= "Explanation: {$event['description']}\n";
			$bbcode_text .= "[/list]\n";
		}

		return $bbcode_text;
	}

	/**
	* @param string $file
	* @return int Number of events found in this file
	* @throws \LogicException
	*/
	public function crawl_php_file($file)
	{
		$this->current_file = $file;
		$this->file_lines = array();
		$content = file_get_contents($this->path . $this->current_file);
		$num_events_found = 0;

		if (strpos($content, 'dispatcher->trigger_event(') || strpos($content, 'dispatcher->dispatch('))
		{
			$this->set_content(explode("\n", $content));
			for ($i = 0, $num_lines = count($this->file_lines); $i < $num_lines; $i++)
			{
				$event_line = false;
				$found_trigger_event = strpos($this->file_lines[$i], 'dispatcher->trigger_event(');
				$arguments = array();
				if ($found_trigger_event !== false)
				{
					$event_line = $i;
					$this->set_current_event($this->get_event_name($event_line, false), $event_line);

					// Find variables of the event
					$arguments = $this->get_vars_from_array();
					$doc_vars = $this->get_vars_from_docblock();
					$this->validate_vars_docblock_array($arguments, $doc_vars);
				}
				else
				{
					$found_dispatch = strpos($this->file_lines[$i], 'dispatcher->dispatch(');
					if ($found_dispatch !== false)
					{
						$event_line = $i;
						$this->set_current_event($this->get_event_name($event_line, true), $event_line);
					}
				}

				if ($event_line)
				{
					// Validate @event
					$event_line_num = $this->find_event();
					$this->validate_event($this->current_event, $this->file_lines[$event_line_num]);

					// Validate @since
					$since_line_num = $this->find_since();
					$since = $this->validate_since($this->file_lines[$since_line_num]);

					$changed_line_nums = $this->find_changed('changed');
					if (empty($changed_line_nums))
					{
						$changed_line_nums = $this->find_changed('change');
					}
					$changed_versions = array();
					if (!empty($changed_line_nums))
					{
						foreach ($changed_line_nums as $changed_line_num)
						{
							$changed_versions[] = $this->validate_changed($this->file_lines[$changed_line_num]);
						}
					}

					if (!$this->version_is_filtered($since))
					{
						$valid_version = false;
						foreach ($changed_versions as $changed)
						{
							$valid_version = $valid_version || $this->version_is_filtered($changed);
						}

						if (!$valid_version)
						{
							continue;
						}
					}

					// Find event description line
					$description_line_num = $this->find_description();
					$description_lines = array();

					while (true)
					{
						$description_line = substr(trim($this->file_lines[$description_line_num]), strlen('*'));
						$description_line = trim(str_replace("\t", " ", $description_line));

						// Reached end of description if line is a tag
						if (strlen($description_line) && $description_line[0] == '@')
						{
							break;
						}

						$description_lines[] = $description_line;
						$description_line_num++;
					}

					// If there is an empty line between description and first tag, remove it
					if (!strlen(end($description_lines)))
					{
						array_pop($description_lines);
					}

					$description = trim(implode('<br>', $description_lines));
					sort($arguments);

					if (isset($this->events[$this->current_event]))
					{
						if ($this->events[$this->current_event]['arguments'] != $arguments ||
							$this->events[$this->current_event]['since'] != $since)
						{
							throw new \LogicException("The event '{$this->current_event}' from file "
								. "'{$this->current_file}:{$event_line_num}' already exists in file "
								. "'{$this->events[$this->current_event]['file']}'", 10);
						}

						$this->events[$this->current_event]['file'] .= '<br>' . $this->current_file;
					}
					else
					{
						$this->events[$this->current_event] = array(
							'event' => $this->current_event,
							'file' => $this->current_file,
							'arguments' => $arguments,
							'since' => $since,
							'description' => $description,
						);
						$num_events_found++;
					}
				}
			}
		}

		return $num_events_found;
	}

	/**
	 * The version to check
	 *
	 * @param string $version
	 * @return bool
	 */
	protected function version_is_filtered($version)
	{
		return (!$this->min_version || phpbb_version_compare($this->min_version, $version, '<='))
			&& (!$this->max_version || phpbb_version_compare($this->max_version, $version, '>='));
	}

	/**
	* Find the name of the event inside the dispatch() line
	*
	* @param int $event_line
	* @param bool $is_dispatch Do we look for dispatch() or trigger_event() ?
	* @return string	Name of the event
	* @throws \LogicException
	*/
	public function get_event_name($event_line, $is_dispatch)
	{
		$event_text_line = $this->file_lines[$event_line];
		$event_text_line = ltrim($event_text_line, "\t ");

		if ($is_dispatch)
		{
			$regex = '#\$[a-z](?:[a-z0-9_]|->)*';
			$regex .= '->dispatch\((\[)?';
			$regex .= '\'' . $this->preg_match_event_name() . '(?(1)\', \'(?2))+\'';
			$regex .= '(?(1)\])\);#';
		}
		else
		{
			$regex = '#extract\(\$[a-z](?:[a-z0-9_]|->)*';
			$regex .= '->trigger_event\((\[)?';
			$regex .= '\'' . $this->preg_match_event_name() . '(?(1)\', \'(?2))+\'';
			$regex .= '(?(1)\]), compact\(\$vars\)\)\);#';
		}

		$match = array();
		preg_match($regex, $event_text_line, $match);
		if (!isset($match[2]))
		{
			throw new \LogicException("Can not find event name in line '{$event_text_line}' "
				. "in file '{$this->current_file}:{$event_line}'", 1);
		}

		return $match[2];
	}

	/**
	* Returns a regex match for the event name
	*
	* @return string
	*/
	protected function preg_match_event_name()
	{
		return '([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)';
	}

	/**
	* Find the $vars array
	*
	* @return array		List of variables
	* @throws \LogicException
	*/
	public function get_vars_from_array()
	{
		$line = ltrim($this->file_lines[$this->current_event_line - 1], "\t");
		if ($line === ');' || $line === '];')
		{
			$vars_array = $this->get_vars_from_multi_line_array();
		}
		else
		{
			$vars_array = $this->get_vars_from_single_line_array($line);
		}

		foreach ($vars_array as $var)
		{
			if (!preg_match('#^[a-z_][a-z0-9_]*$#i', $var))
			{
				throw new \LogicException("Found invalid var '{$var}' in array for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 3);
			}
		}

		sort($vars_array);
		return $vars_array;
	}

	/**
	* Find the variables in single line array
	*
	* @param	string	$line
	* @param	bool	$throw_multiline	Throw an exception when there are too
	*										many arguments in one line.
	* @return array		List of variables
	* @throws \LogicException
	*/
	public function get_vars_from_single_line_array($line, $throw_multiline = true)
	{
		$match = array();
		preg_match('#^\$vars = (?:(\[)|array\()\'([a-z0-9_\' ,]+)\'(?(1)\]|\));$#i', $line, $match);

		if (isset($match[2]))
		{
			$vars_array = explode("', '", $match[2]);
			if ($throw_multiline && count($vars_array) > 6)
			{
				throw new \LogicException('Should use multiple lines for $vars definition '
					. "for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
			}
			return $vars_array;
		}
		else
		{
			throw new \LogicException("Can not find '\$vars = array();'-line for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
		}
	}

	/**
	* Find the variables in single line array
	*
	* @return array		List of variables
	* @throws \LogicException
	*/
	public function get_vars_from_multi_line_array()
	{
		$current_vars_line = 2;
		$var_lines = array();
		while (!in_array(ltrim($this->file_lines[$this->current_event_line - $current_vars_line], "\t"), ['$vars = array(', '$vars = [']))
		{
			$var_lines[] = substr(trim($this->file_lines[$this->current_event_line - $current_vars_line]), 0, -1);

			$current_vars_line++;
			if ($current_vars_line > $this->current_event_line)
			{
				// Reached the start of the file
				throw new \LogicException("Can not find end of \$vars array for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
			}
		}

		return $this->get_vars_from_single_line_array('$vars = array(' . implode(", ", $var_lines) . ');', false);
	}

	/**
	* Find the $vars array
	*
	* @return array		List of variables
	* @throws \LogicException
	*/
	public function get_vars_from_docblock()
	{
		$doc_vars = array();
		$current_doc_line = 1;
		$found_comment_end = false;
		while (ltrim($this->file_lines[$this->current_event_line - $current_doc_line], "\t") !== '/**')
		{
			if (ltrim($this->file_lines[$this->current_event_line - $current_doc_line], "\t ") === '*/')
			{
				$found_comment_end = true;
			}

			if ($found_comment_end)
			{
				$var_line = trim($this->file_lines[$this->current_event_line - $current_doc_line]);
				$var_line = preg_replace('!\s+!', ' ', $var_line);
				if (strpos($var_line, '* @var ') === 0)
				{
					$doc_line = explode(' ', $var_line, 5);
					if (count($doc_line) !== 5)
					{
						throw new \LogicException("Found invalid line '{$this->file_lines[$this->current_event_line - $current_doc_line]}' "
						. "for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
					}
					$doc_vars[] = $doc_line[3];
				}
			}

			$current_doc_line++;
			if ($current_doc_line > $this->current_event_line)
			{
				// Reached the start of the file
				throw new \LogicException("Can not find end of docblock for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
			}
		}

		if (empty($doc_vars))
		{
			// Reached the start of the file
			throw new \LogicException("Can not find @var lines for event '{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 3);
		}

		foreach ($doc_vars as $var)
		{
			if (!preg_match('#^[a-z_][a-z0-9_]*$#i', $var))
			{
				throw new \LogicException("Found invalid @var '{$var}' in docblock for event "
					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 4);
			}
		}

		sort($doc_vars);
		return $doc_vars;
	}

	/**
	* Find the "@since" Information line
	*
	* @return int Absolute line number
	* @throws \LogicException
	*/
	public function find_since()
	{
		return $this->find_tag('since', array('event', 'var'));
	}

	/**
	* Find the "@changed" Information lines
	*
	* @param string $tag_name Should be 'change', not 'changed'
	* @return array Absolute line numbers
	* @throws \LogicException
	*/
	public function find_changed($tag_name)
	{
		$lines = array();
		$last_line = 0;
		try
		{
			while ($line = $this->find_tag($tag_name, array('since'), $last_line))
			{
				$lines[] = $line;
				$last_line = $line;
			}
		}
		catch (\LogicException $e)
		{
			// Not changed? No problem!
		}

		return $lines;
	}

	/**
	* Find the "@event" Information line
	*
	* @return int Absolute line number
	*/
	public function find_event()
	{
		return $this->find_tag('event', array());
	}

	/**
	* Find a "@*" Information line
	*
	* @param string $find_tag		Name of the tag we are trying to find
	* @param array $disallowed_tags		List of tags that must not appear between
	*									the tag and the actual event
	* @param int $skip_to_line		Skip lines until this one
	* @return int Absolute line number
	* @throws \LogicException
	*/
	public function find_tag($find_tag, $disallowed_tags, $skip_to_line = 0)
	{
		$find_tag_line = $skip_to_line ? $this->current_event_line - $skip_to_line + 1 : 0;
		$found_comment_end = ($skip_to_line) ? true : false;
		while (strpos(ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t "), '* @' . $find_tag . ' ') !== 0)
		{
			if ($found_comment_end && ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t") === '/**')
			{
				// Reached the start of this doc block
				throw new \LogicException("Can not find '@{$find_tag}' information for event "
					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
			}

			foreach ($disallowed_tags as $disallowed_tag)
			{
				if ($found_comment_end && strpos(ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t "), '* @' . $disallowed_tag) === 0)
				{
					// Found @var after the @since
					throw new \LogicException("Found '@{$disallowed_tag}' information after '@{$find_tag}' for event "
						. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 3);
				}
			}

			if (ltrim($this->file_lines[$this->current_event_line - $find_tag_line], "\t ") === '*/')
			{
				$found_comment_end = true;
			}

			$find_tag_line++;
			if ($find_tag_line >= $this->current_event_line)
			{
				// Reached the start of the file
				throw new \LogicException("Can not find '@{$find_tag}' information for event "
					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
			}
		}

		return $this->current_event_line - $find_tag_line;
	}

	/**
	* Find a "@*" Information line
	*
	* @return int Absolute line number
	* @throws \LogicException
	*/
	public function find_description()
	{
		$find_desc_line = 0;
		while (ltrim($this->file_lines[$this->current_event_line - $find_desc_line], "\t") !== '/**')
		{
			$find_desc_line++;
			if ($find_desc_line > $this->current_event_line)
			{
				// Reached the start of the file
				throw new \LogicException("Can not find a description for event "
					. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
			}
		}

		$find_desc_line = $this->current_event_line - $find_desc_line + 1;

		$desc = trim($this->file_lines[$find_desc_line]);
		if (strpos($desc, '* @') === 0 || $desc[0] !== '*' || substr($desc, 1) == '')
		{
			// First line of the doc block is a @-line, empty or only contains "*"
			throw new \LogicException("Can not find a description for event "
				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
		}

		return $find_desc_line;
	}

	/**
	* Validate "@since" Information
	*
	* @param string $line
	* @return string
	* @throws \LogicException
	*/
	public function validate_since($line)
	{
		$match = array();
		preg_match('#^\* @since (\d+\.\d+\.\d+(?:-(?:a|b|RC|pl)\d+)?)$#', ltrim($line, "\t "), $match);
		if (!isset($match[1]))
		{
			throw new \LogicException("Invalid '@since' information for event "
				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'");
		}

		return $match[1];
	}

	/**
	* Validate "@changed" Information
	*
	* @param string $line
	* @return string
	* @throws \LogicException
	*/
	public function validate_changed($line)
	{
		$match = array();
		$line = str_replace("\t", ' ', ltrim($line, "\t "));
		preg_match('#^\* @changed (\d+\.\d+\.\d+(?:-(?:a|b|RC|pl)\d+)?)( (?:.*))?$#', $line, $match);
		if (!isset($match[2]))
		{
			throw new \LogicException("Invalid '@changed' information for event "
				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'");
		}

		return $match[2];
	}

	/**
	* Validate "@event" Information
	*
	* @param string $event_name
	* @param string $line
	* @return string
	* @throws \LogicException
	*/
	public function validate_event($event_name, $line)
	{
		$event = substr(ltrim($line, "\t "), strlen('* @event '));

		if ($event !== trim($event))
		{
			throw new \LogicException("Invalid '@event' information for event "
				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 1);
		}

		if ($event !== $event_name)
		{
			throw new \LogicException("Event name does not match '@event' tag for event "
				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'", 2);
		}

		return $event;
	}

	/**
	* Validates that two arrays contain the same strings
	*
	* @param array $vars_array		Variables found in the array line
	* @param array $vars_docblock	Variables found in the doc block
	* @return null
	* @throws \LogicException
	*/
	public function validate_vars_docblock_array($vars_array, $vars_docblock)
	{
		$vars_array = array_unique($vars_array);
		$vars_docblock = array_unique($vars_docblock);
		$sizeof_vars_array = count($vars_array);

		if ($sizeof_vars_array !== count($vars_docblock) || $sizeof_vars_array !== count(array_intersect($vars_array, $vars_docblock)))
		{
			throw new \LogicException("\$vars array does not match the list of '@var' tags for event "
				. "'{$this->current_event}' in file '{$this->current_file}:{$this->current_event_line}'");
		}
	}
}
