<?php
/**
 * This file contains core of the code for Mentions
 *
 * Simple Machines Forum (SMF)
 *
 * @package SMF
 * @author Simple Machines https://www.simplemachines.org
 * @copyright 2025 Simple Machines and individual contributors
 * @license https://www.simplemachines.org/about/smf/license.php BSD
 *
 * @version 2.1.7
 */

/**
 * This really is a pseudo class, I couldn't justify having instance of it
 * while mentioning so I just made every method static
 */
class Mentions
{
	/**
	 * @var string The character used for mentioning users
	 */
	protected static $char = '@';

	/**
	 * @var string Regular expression matching BBC that can't contain mentions
	 */
	protected static $excluded_bbc_regex = '';

	/**
	 * Returns mentions for a specific content
	 *
	 * @static
	 * @access public
	 * @param string $content_type The content type
	 * @param int $content_id The ID of the desired content
	 * @param array $members Whether to limit to a specific set of members
	 * @return array An array of arrays containing info about each member mentioned
	 */
	public static function getMentionsByContent($content_type, $content_id, array $members = array())
	{
		global $smcFunc;

		$request = $smcFunc['db_query']('', '
			SELECT mem.id_member, mem.real_name, mem.email_address, mem.id_group, mem.id_post_group, mem.additional_groups,
				mem.lngfile, ment.id_member AS id_mentioned_by, ment.real_name AS mentioned_by_name,
				FIND_IN_SET(ment.id_member, mem.pm_ignore_list) AS mentioned_by_ignored
			FROM {db_prefix}mentions AS m
				INNER JOIN {db_prefix}members AS mem ON (mem.id_member = m.id_mentioned)
				INNER JOIN {db_prefix}members AS ment ON (ment.id_member = m.id_member)
			WHERE content_type = {string:type}
				AND content_id = {int:id}' . (!empty($members) ? '
				AND mem.id_member IN ({array_int:members})' : ''),
			array(
				'type' => $content_type,
				'id' => $content_id,
				'members' => (array) $members,
			)
		);
		$members = array();
		while ($row = $smcFunc['db_fetch_assoc']($request))
			$members[$row['id_member']] = array(
				'id' => $row['id_member'],
				'real_name' => $row['real_name'],
				'email_address' => $row['email_address'],
				'groups' => array_unique(array_merge(array($row['id_group'], $row['id_post_group']), explode(',', $row['additional_groups']))),
				'mentioned_by' => array(
					'id' => $row['id_mentioned_by'],
					'name' => $row['mentioned_by_name'],
					// Note that PostgreSQL can return a lowercase t/f for FIND_IN_SET
					'ignored' => !empty($row['mentioned_by_ignored']) && $row['mentioned_by_ignored'] != 'f',
				),
				'lngfile' => $row['lngfile'],
			);
		$smcFunc['db_free_result']($request);

		return $members;
	}

	/**
	 * Inserts mentioned members
	 *
	 * @static
	 * @access public
	 * @param string $content_type The content type
	 * @param int $content_id The ID of the specified content
	 * @param array $members An array of members who have been mentioned
	 * @param int $id_member The ID of the member who mentioned them
	 */
	public static function insertMentions($content_type, $content_id, array $members, $id_member)
	{
		global $smcFunc;

		call_integration_hook('mention_insert_' . $content_type, array($content_id, &$members));

		foreach ($members as $member)
			$smcFunc['db_insert']('ignore',
				'{db_prefix}mentions',
				array('content_id' => 'int', 'content_type' => 'string', 'id_member' => 'int', 'id_mentioned' => 'int', 'time' => 'int'),
				array((int) $content_id, $content_type, $id_member, $member['id'], time()),
				array('content_id', 'content_type', 'id_mentioned')
			);
	}

	/**
	 * Updates list of mentioned members.
	 *
	 * Intended for use when a post is modified.
	 *
	 * @static
	 * @access public
	 * @param string $content_type The content type
	 * @param int $content_id The ID of the specified content
	 * @param array $members An array of members who have been mentioned
	 * @param int $id_member The ID of the member who mentioned them
	 * @return array An array of unchanged, removed, and added member IDs.
	 */
	public static function modifyMentions($content_type, $content_id, array $members, $id_member)
	{
		global $smcFunc;

		$existing_members = self::getMentionsByContent($content_type, $content_id);

		$members_to_remove = array_diff_key($existing_members, $members);
		$members_to_insert = array_diff_key($members, $existing_members);
		$members_unchanged = array_diff_key($existing_members, $members_to_remove, $members_to_insert);

		// Delete mentions from the table that have been deleted in the content.
		if (!empty($members_to_remove))
			$smcFunc['db_query']('', '
				DELETE FROM {db_prefix}mentions
				WHERE content_type = {string:type}
					AND content_id = {int:id}
					AND id_mentioned IN ({array_int:members})',
				array(
					'type' => $content_type,
					'id' => $content_id,
					'members' => array_keys($members_to_remove),
				)
			);

		// Insert any new mentions.
		if (!empty($members_to_insert))
			self::insertMentions($content_type, $content_id, $members_to_insert, $id_member);

		return array(
			'unchanged' => $members_unchanged,
			'removed' => $members_to_remove,
			'added' => $members_to_insert,
		);
	}

	/**
	 * Gets appropriate mentions replaced in the body
	 *
	 * @static
	 * @access public
	 * @param string $body The text to look for mentions in
	 * @param array $members An array of arrays containing info about members (each should have 'id' and 'member')
	 * @return string The body with mentions replaced
	 */
	public static function getBody($body, array $members)
	{
		if (empty($body))
			return $body;

		foreach ($members as $member)
			$body = str_ireplace(static::$char . $member['real_name'], '[member=' . $member['id'] . ']' . $member['real_name'] . '[/member]', $body);

		return $body;
	}

	/**
	 * Takes a piece of text and finds all the mentioned members in it
	 *
	 * @static
	 * @access public
	 * @param string $body The body to get mentions from
	 * @return array An array of arrays containing members who were mentioned (each has 'id_member' and 'real_name')
	 */
	public static function getMentionedMembers($body)
	{
		global $smcFunc;

		if (empty($body))
			return array();

		$possible_names = self::getPossibleMentions($body);
		$existing_mentions = self::getExistingMentions($body);

		if ((empty($possible_names) && empty($existing_mentions)) || !allowedTo('mention'))
			return array();

		// Make sure we don't pass empty arrays to the query.
		if (empty($existing_mentions))
			$existing_mentions = array(0 => '');
		if (empty($possible_names))
			$possible_names = $existing_mentions;

		$request = $smcFunc['db_query']('', '
			SELECT id_member, real_name
			FROM {db_prefix}members
			WHERE id_member IN ({array_int:ids})
				OR real_name IN ({array_string:names})
			ORDER BY LENGTH(real_name) DESC
			LIMIT {int:count}',
			array(
				'ids' => array_keys($existing_mentions),
				'names' => $possible_names,
				'count' => count($possible_names),
			)
		);
		$members = array();
		while ($row = $smcFunc['db_fetch_assoc']($request))
		{
			if (!isset($existing_mentions[$row['id_member']]) && stripos($body, static::$char . $row['real_name']) === false)
				continue;

			$members[$row['id_member']] = array(
				'id' => $row['id_member'],
				'real_name' => $row['real_name'],
			);
		}
		$smcFunc['db_free_result']($request);

		return $members;
	}

	/**
	 * Parses a body in order to see if there are any mentions, returns possible mention names
	 *
	 * Names are tagged by "@<username>" format in post, but they can contain
	 * any type of character up to 60 characters length. So we extract, starting from @
	 * up to 60 characters in length (or if we encounter a line break) and make
	 * several combination of strings after splitting it by anything that's not a word and join
	 * by having the first word, first and second word, first, second and third word and so on and
	 * search every name.
	 *
	 * One potential problem with this is something like "@Admin Space" can match
	 * "Admin Space" as well as "Admin", so we sort by length in descending order.
	 * One disadvantage of this is that we can only match by one column, hence I've chosen
	 * real_name since it's the most obvious.
	 *
	 * If there's an @ symbol within the name, it is counted in the ongoing string and a new
	 * combination string is started from it as well in order to account for all the possibilities.
	 * This makes the @ symbol to not be required to be escaped
	 *
	 * @static
	 * @access protected
	 * @param string $body The text to look for mentions in
	 * @return array An array of names of members who have been mentioned
	 */
	protected static function getPossibleMentions($body)
	{
		global $smcFunc;

		if (empty($body))
			return array();

		// preparse code does a few things which might mess with our parsing
		$body = htmlspecialchars_decode(preg_replace('~<br\s*/?'.'>~', "\n", str_replace('&nbsp;', ' ', $body)), ENT_QUOTES);

		if (empty(self::$excluded_bbc_regex))
			self::setExcludedBbcRegex();

		// Exclude the content of various BBCodes.
		$body = preg_replace('~\[(' . self::$excluded_bbc_regex . ')[^\]]*\](?' . '>(?' . '>[^\[]|\[(?!/?\1[^\]]*\]))|(?0))*\[/\1\]~', '', $body);

		$matches = array();
		// Split before every Unicode character.
		$string = preg_split('/(?=\X)/u', $body, -1, PREG_SPLIT_NO_EMPTY);
		$depth = 0;
		foreach ($string as $k => $char)
		{
			if ($char == static::$char && ($k == 0 || trim($string[$k - 1]) == ''))
			{
				$depth++;
				$matches[] = array();
			}
			elseif ($char == "\n")
				$depth = 0;

			for ($i = $depth; $i > 0; $i--)
			{
				if (count($matches[count($matches) - $i]) > 60)
				{
					$depth--;
					continue;
				}
				$matches[count($matches) - $i][] = $char;
			}
		}

		foreach ($matches as $k => $match)
			$matches[$k] = substr(implode('', $match), 1);

		// Names can have spaces, other breaks, or they can't...we try to match every possible
		// combination.
		$names = array();
		foreach ($matches as $match)
		{
			// '[^\p{L}\p{M}\p{N}_]' is the Unicode equivalent of '[^\w]'
			$match = preg_split('/([^\p{L}\p{M}\p{N}_])/u', $match, -1, PREG_SPLIT_DELIM_CAPTURE);
			$count = count($match);

			for ($i = 1; $i <= $count; $i++)
				$names[] = $smcFunc['htmlspecialchars']($smcFunc['htmltrim'](implode('', array_slice($match, 0, $i))), ENT_QUOTES);
		}

		$names = array_unique($names);

		return $names;
	}

	/**
	 * Like getPossibleMentions(), but for `[member=1]name[/member]` format.
	 *
	 * @static
	 * @access public
	 * @param string $body The text to look for mentions in.
	 * @param array $members An array of arrays containing info about members (each should have 'id' and 'member').
	 * @return array An array of arrays containing info about members that are in fact mentioned in the body.
	 */
	public static function getExistingMentions($body)
	{
		if (empty(self::$excluded_bbc_regex))
			self::setExcludedBbcRegex();

		// Don't include mentions inside quotations, etc.
		$body = preg_replace('~\[(' . self::$excluded_bbc_regex . ')[^\]]*\](?' . '>(?' . '>[^\[]|\[(?!/?\1[^\]]*\]))|(?0))*\[/\1\]~', '', $body);

		$existing_mentions = array();

		preg_match_all('~\[member=([0-9]+)\]([^\[]*)\[/member\]~', $body, $matches, PREG_SET_ORDER);

		foreach ($matches as $match_set)
			$existing_mentions[$match_set[1]] = trim($match_set[2]);

		return $existing_mentions;
	}

	/**
	 * Verifies that members really are mentioned in the text.
	 *
	 * This function assumes the incoming text has already been processed by
	 * the Mentions::getBody() function.
	 *
	 * @static
	 * @access public
	 * @param string $body The text to look for mentions in.
	 * @param array $members An array of arrays containing info about members (each should have 'id' and 'member').
	 * @return array An array of arrays containing info about members that are in fact mentioned in the body.
	 */
	public static function verifyMentionedMembers($body, array $members)
	{
		if (empty($body))
			return array();

		if (empty(self::$excluded_bbc_regex))
			self::setExcludedBbcRegex();

		// Don't include mentions inside quotations, etc.
		$body = preg_replace('~\[(' . self::$excluded_bbc_regex . ')[^\]]*\](?' . '>(?' . '>[^\[]|\[(?!/?\1[^\]]*\]))|(?0))*\[/\1\]~', '', $body);

		foreach ($members as $member)
		{
			if (strpos($body, '[member=' . $member['id'] . ']' . $member['real_name'] . '[/member]') === false)
				unset($members[$member['id']]);
		}

		return $members;
	}

	/**
	 * Retrieves info about the authors of posts quoted in a block of text.
	 *
	 * @static
	 * @access public
	 * @param string $body A block of text, such as the body of a post.
	 * @param int $poster_id The member ID of the author of the text.
	 * @return array Info about any members who were quoted.
	 */
	public static function getQuotedMembers($body, $poster_id)
	{
		global $smcFunc;

		if (empty($body))
			return array();

		$blocks = preg_split('/(\[quote.*?\]|\[\/quote\])/i', $body, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);

		$quote_level = 0;
		$message = '';

		foreach ($blocks as $block)
		{
			if (preg_match('/\[quote(.*)?\]/i', $block, $matches))
			{
				if ($quote_level == 0)
					$message .= '[quote' . $matches[1] . ']';
				$quote_level++;
			}
			elseif (preg_match('/\[\/quote\]/i', $block))
			{
				if ($quote_level <= 1)
					$message .= '[/quote]';
				if ($quote_level >= 1)
				{
					$quote_level--;
					$message .= "\n";
				}
			}
			elseif ($quote_level <= 1)
				$message .= $block;
		}

		preg_match_all('/\[quote.*?link=msg=([0-9]+).*?\]/i', $message, $matches);

		$id_msgs = $matches[1];
		foreach ($id_msgs as $k => $id_msg)
			$id_msgs[$k] = (int) $id_msg;

		if (empty($id_msgs))
			return array();

		// Get the messages
		$request = $smcFunc['db_query']('', '
			SELECT m.id_member AS id, mem.email_address, mem.lngfile, mem.real_name
			FROM {db_prefix}messages AS m
				INNER JOIN {db_prefix}members AS mem ON (mem.id_member = m.id_member)
			WHERE id_msg IN ({array_int:msgs})
			LIMIT {int:count}',
			array(
				'msgs' => array_unique($id_msgs),
				'count' => count(array_unique($id_msgs)),
			)
		);

		$members = array();
		while ($row = $smcFunc['db_fetch_assoc']($request))
		{
			if ($poster_id == $row['id'])
				continue;

			$members[$row['id']] = $row;
		}

		return $members;
	}

	/**
	 * Builds a regular expression matching BBC that can't contain mentions.
	 *
	 * @static
	 * @access protected
	 */
	protected static function setExcludedBbcRegex()
	{
		if (empty(self::$excluded_bbc_regex))
		{
			// Exclude quotes. We don't want to get double mentions.
			$excluded_bbc = array('quote');

			// Exclude everything with unparsed content.
			foreach (parse_bbc(false) as $code)
			{
				if (!empty($code['type']) && in_array($code['type'], array('unparsed_content', 'unparsed_commas_content', 'unparsed_equals_content')))
					$excluded_bbc[] = $code['tag'];
			}

			self::$excluded_bbc_regex = build_regex($excluded_bbc, '~');
		}
	}
}

?>