<?php
/**
 * @brief		Abstract Content Model
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		3 Oct 2013
 */

namespace IPS;

/* To prevent PHP errors (extending class does not exist) revealing path */

use BadMethodCallException;
use Exception;
use InvalidArgumentException;
use IPS\Api\Webhook;
use IPS\Content\Anonymous;
use IPS\Content\Assignable;
use IPS\Content\Comment;
use IPS\Content\Followable;
use IPS\Content\Hideable;
use IPS\Content\Item;
use IPS\Content\Review;
use IPS\Content\Solvable;
use IPS\core\Approval;
use IPS\core\DataLayer;
use IPS\core\Reports\Report;
use IPS\core\Reports\Types;
use IPS\Db\Select;
use IPS\Events\Event;
use IPS\Http\Url;
use IPS\Member\Group;
use IPS\Node\Model;
use IPS\Output\UI\UiExtension;
use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Platform\Bridge;
use IPS\Session\Store;
use IPS\Text\Parser;
use IPS\Xml\DOMDocument;
use LogicException;
use OutOfRangeException;
use RuntimeException;
use UnderflowException;
use UnexpectedValueException;
use function class_implements;
use function count;
use function defined;
use function func_get_args;
use function get_called_class;
use function get_class;
use function in_array;
use function intval;
use function is_array;
use function is_int;
use function substr;
use function ucfirst;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Abstract Content Model
 * We need PHPStorm to understand that trait methods are available so code completion and analyzing works correctly. PHPStorm can not
 * figure it out because we have these application classes which extend Content and use the traits, so it'll understand from the perspective
 * of \IPS\forums\Topic as it has the trait 'use'd but cannot understand from the perspective of \IPS\Content\Item because those traits are not
 * 'use'd directly. If this really bothers you, toggle the V next to the start of the doc block. :)
 *
 * If you need to update it, just run dev/generateContentTraitMethodTags.php
 *
 * @mixin \IPS\Content\Anonymous
 * @mixin \IPS\Content\Assignable
 * @mixin \IPS\Content\EditHistory
 * @mixin \IPS\Content\Followable
 * @mixin \IPS\Content\FuturePublishing
 * @mixin \IPS\Content\Helpful
 * @mixin \IPS\Content\Hideable
 * @mixin \IPS\Content\ItemTopic
 * @mixin \IPS\Content\Lockable
 * @mixin \IPS\Content\MetaData
 * @mixin \IPS\Content\Featurable
 * @mixin \IPS\Content\Pinnable
 * @mixin \IPS\Content\Polls
 * @mixin \IPS\Content\Ratings
 * @mixin \IPS\Content\Reactable
 * @mixin \IPS\Content\ReadMarkers
 * @mixin \IPS\Content\Recognizable
 * @mixin \IPS\Content\Reportable
 * @mixin \IPS\Content\Shareable
 * @mixin \IPS\Content\Solvable
 * @mixin \IPS\Content\Statistics
 * @mixin \IPS\Content\Taggable
 */
abstract class Content extends ActiveRecord
{
	/**
	 * @brief	[Content\Comment]	Database Column Map
	 */
	public static array $databaseColumnMap = array();
	
	/**
	 * @brief	[Content]	Key for hide reasons
	 */
	public static string|null $hideLogKey = NULL;
	
	/**
	 * @brief	[Content\Comment]	Language prefix for forms
	 */
	public static string $formLangPrefix = '';

	/**
	 * @brief	Include In Sitemap
	 */
	public static bool $includeInSitemap = TRUE;
	
	/**
	 * @brief	Reputation Store
	 */
	protected array|null $reputation = null;
	
	/**
	 * @brief	Can this content be moderated normally from the front-end (will be FALSE for things like Pages and Commerce Products)
	 */
	public static bool $canBeModeratedFromFrontend = TRUE;

	/**
	 * An array containing data layer event keys to bypass. Optionally, this can be set to 'true' to bypass all events.
	 * @var bool|string[]
	 */
	protected static array|bool $_bypassDataLayerEvents = [];

	/**
	 * Should posting this increment the poster's post count?
	 *
	 * @param	Model|NULL	$container	Container
	 * @return	bool
	 */
	public static function incrementPostCount( Model $container = NULL ): bool
	{
		return TRUE;
	}

	/**
	 * Post count for member
	 *
	 * @param Member $member								The member
	 * @param	bool		$includeNonPostCountIncreasing		If FALSE, will skip any posts which would not cause the user's post count to increase
	 * @param	bool		$includeHiddenAndPendingApproval	If FALSE, will skip any hidden posts, or posts pending approval
	 * @return	int
	 */
	public static function memberPostCount( Member $member, bool $includeNonPostCountIncreasing = FALSE, bool $includeHiddenAndPendingApproval = TRUE ): int
	{
		if ( !isset( static::$databaseColumnMap['author'] ) )
		{
			return 0;
		}
		
		if ( !$includeNonPostCountIncreasing and !static::incrementPostCount() )
		{
			return 0;
		}
		
		$where = [];
		$where[] = [ static::$databasePrefix . static::$databaseColumnMap['author'] . '=?', $member->member_id ];
		
		if ( !$includeHiddenAndPendingApproval )
		{
			if ( isset( static::$databaseColumnMap['hidden'] ) )
			{
				$where[] = [ static::$databasePrefix . static::$databaseColumnMap['hidden'] . '=0' ];
			}
			if ( isset( static::$databaseColumnMap['approved'] ) )
			{
				$where[] = [ static::$databasePrefix . static::$databaseColumnMap['approved'] . '=1' ];
			}
		}
		
		return Db::i()->select( 'COUNT(*)', static::$databaseTable, $where )->first();
	}

	/**
	 * Post count for member
	 *
	 * @param	Member	$member	The member
	 * @return	int
	 *@deprecated	Use options provided to memberPostCount()
	 *
	 */
	public static function rawMemberPostCount( Member $member ): int
	{
		return static::memberPostCount( $member, TRUE );
	}
	
	/**
	 * Members with most contributions
	 *
	 * @param	int	$count	The number of results to return
	 * @return	array
	 */
	public static function mostContributions( int $count = 5 ): array
	{
		if( !isset( static::$databaseColumnMap['author'] ) )
		{
			return array( 'counts' => NULL, 'members' => NULL );
		}

		$where = array();
		if( isset( static::$databaseColumnMap['approved'] ) )
		{
			$approvedColumn = static::$databasePrefix . static::$databaseColumnMap['approved'];
			$where[] = array( "{$approvedColumn} = 1" );
		}
		if( isset( static::$databaseColumnMap['hidden'] ) )
		{
			$hiddenColumn = static::$databasePrefix . static::$databaseColumnMap['hidden'];
			$where[] = array( "{$hiddenColumn} = 0" );
		}

		$authorColumn = static::$databasePrefix . static::$databaseColumnMap['author'];
		$members = Db::i()->select( "count(*) as sum, {$authorColumn}", static::$databaseTable, $where, 'sum DESC', array( 0, $count ), array( static::$databasePrefix . static::$databaseColumnMap['author'] ) );

		$contributors = array();
		$counts = array();
		foreach ( $members as $member )
		{
			$contributors[] = $member[ $authorColumn ];
			$counts[ $member[ $authorColumn ] ] = $member[ 'sum' ];
		}

		if ( empty( $contributors ) )
		{
			return array( 'counts' => NULL, 'members' => NULL );
		}

		return array( 'counts' => $counts, 'members' => new ActiveRecordIterator( Db::i()->select( '*', 'core_members', array( Db::i()->in( 'member_id', $contributors ) ), "FIND_IN_SET( member_id, '" . implode( ",", $contributors) . "' )" ), 'IPS\Member' ) );
	}
		
	/**
	 * Load and check permissions
	 *
	 * @param	mixed				$id		ID
	 * @param	Member|NULL	$member	Member, or NULL for logged in member
	 * @return	static
	 * @throws	OutOfRangeException
	 */
	public static function loadAndCheckPerms( mixed $id, Member|null $member = NULL ): static
	{
		$obj = static::load( $id );
		
		$member = $member ?: Member::loggedIn();
		if ( !$obj->canView( $member ) )
		{
			throw new OutOfRangeException;
		}

		return $obj;
	}
	
	/**
	 * Construct ActiveRecord from database row
	 *
	 * @param array $data							Row from database table
	 * @param bool $updateMultitonStoreIfExists	Replace current object in multiton store if it already exists there?
	 * @return    static
	 */
	public static function constructFromData( array $data, bool $updateMultitonStoreIfExists = TRUE ): static
	{
	    if ( isset( $data[ static::$databaseTable ] ) and is_array( $data[ static::$databaseTable ] ) )
	    {
	        /* Add author data to multiton store to prevent ->author() running another query later */
	        if ( isset( $data['author'] ) and is_array( $data['author'] ) )
	        {
	           	$author = Member::constructFromData( $data['author'], FALSE );

	            if ( isset( $data['author_pfields'] ) )
	            {
		            unset( $data['author_pfields']['member_id'] );
					/* @var Member $author */
					$author->contentProfileFields( $data['author_pfields'] );
	            }
	        }

	        /* Load content */
	        $obj = parent::constructFromData( $data[ static::$databaseTable ], $updateMultitonStoreIfExists );

			/* Add reputation if it was passed*/
			if ( isset( $data['core_reputation_index'] ) and is_array( $data['core_reputation_index'] ) )
			{
				$obj->_data = array_merge( $obj->_data, $data['core_reputation_index'] );
			}
		}
		else
		{
			$obj = parent::constructFromData( $data, $updateMultitonStoreIfExists );
		}

        /* Return */
        return $obj;
    }

    /**
     * @brief	Cached social groups
     */
    protected static array $_cachedSocialGroups = array();

	/**
	 * Get WHERE clause for Social Group considerations for getItemsWithPermission
	 *
	 * @param string $socialGroupColumn The column which contains the social group ID
	 * @param Member|null $member The member (NULL to use currently logged in member)
	 * @return    string
	 */
	public static function socialGroupGetItemsWithPermissionWhere( string $socialGroupColumn, Member|null $member = NULL ): string
	{			
		$socialGroups = array();
		
		$member = $member ?: Member::loggedIn();
		if ( $member->member_id )
		{
			if( !array_key_exists( $member->member_id, static::$_cachedSocialGroups ) )
			{
				static::$_cachedSocialGroups[ $member->member_id ] = iterator_to_array( Db::i()->select( 'group_id', 'core_sys_social_group_members', array( 'member_id=?', $member->member_id ) ) );
			}

			$socialGroups = static::$_cachedSocialGroups[ $member->member_id ];
		}

		if ( count( $socialGroups ) )
		{
			return $socialGroupColumn . '=0 OR ( ' . Db::i()->in( $socialGroupColumn, $socialGroups ) . ' )';
		}
		else
		{
			return $socialGroupColumn . '=0';
		}
	}

	/**
	 * Check the request for legacy parameters we may need to redirect to
	 *
	 * @return	NULL|Url
	 */
	public function checkForLegacyParameters(): Url|NULL
	{
		$paramsToSet	= array();
		$paramsToUnset	= array();

		/* st=20 needs to go to page=2 (or whatever the comments per page setting is set to) */
		if( isset( Request::i()->st ) )
		{
			$commentsPerPage = static::getCommentsPerPage();

			$paramsToSet['page']	= floor( intval( Request::i()->st ) / $commentsPerPage ) + 1;
			$paramsToUnset[]		= 'st';
		}

		/* Did we have any? */
		if( count( $paramsToSet ) )
		{
			$url = $this->url();

			if( count( $paramsToUnset ) )
			{
				$url = $url->stripQueryString( $paramsToUnset );
			}

			return $url->setQueryString( $paramsToSet );
		}

		return NULL;
	}

	/**
	 * Get mapped value
	 *
	 * @param string $key	date,content,ip_address,first
	 * @return	mixed
	 */
	public function mapped( string $key ): mixed
	{
		if ( isset( static::$databaseColumnMap[ $key ] ) )
		{
			$field = static::$databaseColumnMap[ $key ];
			
			if ( is_array( $field ) )
			{
				$field = array_pop( $field );
			}
			
			return $this->$field;
		}
		return NULL;
	}
	
	/**
	 * Get author
	 *
	 * @return	Member
	 */
	public function author(): Member
	{
		if( IPS::classUsesTrait( $this, 'IPS\Content\Anonymous' ) AND $this->isAnonymous() )
		{
			$guest = new Member;
			$guest->name = Member::loggedIn()->language()->get( "post_anonymously_placename" );
			return $guest;
		}
		elseif ( $this->mapped('author') or !isset( static::$databaseColumnMap['author_name'] ) or !$this->mapped('author_name') )
		{
			return Member::load( $this->mapped('author') );
		} 
		else
		{
			$guest = new Member;
			$guest->name = $this->mapped('author_name');
			return $guest;
		}
	}

	/**
	 * Returns the content
	 *
	 * @return string|null
	 */
	public function content(): ?string
	{
		return $this->mapped('content');
	}

	/**
	 * Text for use with data-ipsTruncate
	 * Returns the post with paragraphs turned into line breaks
	 *
	 * @param	bool		$oneLine	If TRUE, will use spaces instead of line breaks. Useful if using a single line display.
	 * @param	int|null	$length		If supplied, and $oneLine is set to TRUE, the returned content will be truncated to this length
	 * @return	string
	 * @note	For now we are removing all HTML. If we decide to change this to remove specific tags in future, we can use \IPS\Text\Parser::removeElements( $this->content() )
	 */
	public function truncated( bool $oneLine=FALSE, int|null $length=500 ): string
	{
		$content = $this->content();
		if( $content === null )
		{
			return '';
		}

		return Parser::truncate( $content, $oneLine, $length );
	}
	
	/**
	 * Delete Record
	 *
	 * @return    void
	 */
	public function delete(): void
	{
		$idColumn = static::$databaseColumnId;
		
		if ( IPS::classUsesTrait( $this, 'IPS\Content\Reactable' ) )
		{
			Db::i()->delete( 'core_reputation_index', array( 'app=? AND type=? AND type_id=?', static::$application, $this->reactionType(), $this->$idColumn ) );
		}

		if ( IPS::classUsesTrait( $this, 'IPS\Content\Recognizable' ) )
		{
			Db::i()->delete( 'core_member_recognize', array( 'r_content_class=? AND r_content_id=?', get_class( $this ), $this->$idColumn ) );
		}

		if( IPS::classUsesTrait( $this, 'IPS\Content\Anonymous' ) )
		{
			Db::i()->delete( 'core_anonymous_posts', array( 'anonymous_object_class=? AND anonymous_object_id=?', get_class( $this ), $this->$idColumn ) );
		}

		/* Remove any entries in the promotions table */
		if( IPS::classUsesTrait( $this, 'IPS\Content\Featurable' ) )
		{
			Db::i()->delete( 'core_content_promote', array( 'promote_class=? AND promote_class_id=?', get_class( $this ), $this->$idColumn ) );
		}
		
		Db::i()->delete( 'core_deletion_log', array( "dellog_content_class=? AND dellog_content_id=?", get_class( $this ), $this->$idColumn ) );
		Db::i()->delete( 'core_solved_index', array( 'comment_class=? and comment_id=?', get_class( $this ), $this->$idColumn ) );

		if ( static::$hideLogKey )
		{
			$idColumn = static::$databaseColumnId;
			Db::i()->delete('core_soft_delete_log', array('sdl_obj_id=? AND sdl_obj_key=?', $this->$idColumn, static::$hideLogKey));
		}

		Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_delete', $this, $this->webhookFilters() );
		
		try
		{
			Approval::loadFromContent( get_called_class(), $this->$idColumn )->delete();
		}
		catch( OutOfRangeException $e ) { }

		parent::delete();

		$this->expireWidgetCaches();
		$this->adjustSessions();
	}

	/**
	 * Wrapper for the "hidden" method because we have
	 * way too many places that reference it
	 *
	 * @return int
	 */
	public function hidden() : int
	{
		if( IPS::classUsesTrait( $this, Hideable::class ) )
		{
			return $this->hiddenStatus();
		}

		return 0;
	}
	
	/**
	 * Can see moderation tools
	 *
	 * @note	This is used generally to control if the user has permission to see multi-mod tools. Individual content items may have specific permissions
	 * @param	Member|NULL	$member	The member to check for or NULL for the currently logged in member
	 * @param	Model|NULL		$container	The container
	 * @return	bool
	 */
	public static function canSeeMultiModTools( Member|null $member = NULL, Model|null $container = NULL ): bool
	{
		return static::modPermission( 'pin', $member, $container ) or static::modPermission( 'unpin', $member, $container ) or static::modPermission( 'feature', $member, $container ) or static::modPermission( 'unfeature', $member, $container ) or static::modPermission( 'edit', $member, $container ) or static::modPermission( 'hide', $member, $container ) or static::modPermission( 'unhide', $member, $container ) or static::modPermission( 'delete', $member, $container ) or static::modPermission( 'move', $member, $container );
	}

	/**
	 * Return a list of groups that cannot see this item
	 *
	 * @return 	NULL|array
	 */
	public function cannotViewGroups(): array|NULL
	{
		$groups = array();
		foreach( Group::groups() as $group )
		{
			if ( $this instanceof Comment )
			{
				if ( ! $this->item()->can( 'view', $group ) )
				{
					$groups[] = $group->name;
				}
			}
			else
			{
				if ( ! $this->can( 'view', $group, FALSE ) )
				{
					$groups[] = $group->name;
				}
			}
		}

		return count( $groups ) ? $groups : NULL;
	}
	
	/**
	 * Check Moderator Permission
	 *
	 * @param	string						$type		'edit', 'hide', 'unhide', 'delete', etc.
	 * @param	Member|NULL			$member		The member to check for or NULL for the currently logged in member
	 * @param	Model|NULL		$container	The container
	 * @return	bool
	 */
	public static function modPermission( string $type, ?Member $member = NULL, ?Model $container = NULL ): bool
	{
		/* Compatibility checks */
		if ( ( $type == 'hide' or $type == 'unhide' ) and !IPS::classUsesTrait( get_called_class(), 'IPS\Content\Hideable' ) )
		{
			return FALSE;
		}
		if ( ( $type == 'pin' or $type == 'unpin' ) and !IPS::classUsesTrait( get_called_class(), 'IPS\Content\Pinnable' ) )
		{
			return FALSE;
		}
		if ( ( $type == 'future_publish' ) and !IPS::classUsesTrait( get_called_class(), 'IPS\Content\FuturePublishing' ) )
		{
			return FALSE;
		}
		if( ( $type == 'assign' ) and ( !IPS::classUsesTrait( get_called_class(), Assignable::class ) or !Bridge::i()->featureIsEnabled( 'assignments' ) ) )
		{
			return false;
		}

		/* If this is called from a gateway script, i.e. email piping, just return false as we are a "guest" */
		if( $member === NULL AND !Dispatcher::hasInstance() )
		{
			return FALSE;
		}
		
		/* Load Member */
		$member = $member ?: Member::loggedIn();

		/* Global permission */
		if ( $member->modPermission( "can_{$type}_content" ) )
		{
			return TRUE;
		}
		/* Per-container permission */
		elseif ( $container )
		{
			return $container->modPermission( $type, $member, static::getContainerModPermissionClass() ?: get_called_class() );
		}
		
		/* Still here? return false */
		return FALSE;
	}

	/**
	 * Get the content to use for mod permission checks
	 *
	 * @return	string|NULL
	 * @note	By default we will return NULL and the container check will execute against Node::$contentItemClass, however
	 *	in some situations we may need to override this (i.e. for Gallery Albums)
	 */
	protected static function getContainerModPermissionClass(): ?string
	{
		return NULL;
	}

	/**
	 * @brief	Flag to skip rebuilding container data (because it will be rebuilt in one batch later)
	 */
	public bool $skipContainerRebuild = FALSE;

	/**
	 * Check if a specific action is available for this Content.
	 * Default to TRUE, but used for overrides in individual Item/Comment classes.
	 *
	 * @param string $action
	 * @param Member|null $member
	 * @return bool
	 */
	public function actionEnabled( string $action, ?Member $member=null ) : bool
	{
		return TRUE;
	}
		
	/**
	 * Do Moderator Action
	 *
	 * @param string $action	The action
	 * @param Member|NULL	$member	The member doing the action (NULL for currently logged in member)
	 * @param string|null $reason	Reason (for hides)
	 * @param bool $immediately Delete Immediately
	 * @return	void
	 * @throws	OutOfRangeException|InvalidArgumentException|RuntimeException
	 */
	public function modAction( string $action, ?Member $member = NULL, mixed $reason = NULL, bool $immediately=FALSE ): void
	{
		if( $action === 'approve' )
		{
			$action	= 'unhide';
		}

		/* Check it's a valid action */
		if ( !in_array( $action, array( 'pin', 'unpin', 'feature', 'unfeature', 'hide', 'unhide', 'move', 'lock', 'unlock', 'delete', 'publish', 'restore', 'restoreAsHidden' ) ) )
		{
			throw new InvalidArgumentException;
		}
		
		/* And that we can do it */
		$toCheck = $action;
		if ( $action == 'restoreAsHidden' )
		{
			$toCheck = 'restore';
		}
		
		$methodName = 'can' . ucfirst( $toCheck );
		if ( !$this->$methodName( $member ) )
		{
			throw new OutOfRangeException;
		}
		
		/* Log */
		Session::i()->modLog( 'modlog__action_' . $action, array( static::$title => TRUE, $this->url()->__toString() => FALSE, $this->mapped('title') ?: ( method_exists( $this, 'item' ) ? $this->item()->mapped('title') : NULL ) => FALSE ), ( $this instanceof Item ) ? $this : $this->item() );

		$idColumn = static::$databaseColumnId;

		Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_modaction', ['action' => $action, 'item' => $this->apiOutput()], $this->webhookFilters() );

		/* These ones just need a property setting */
		$dataLayerEvent = null;
		if ( in_array( $action, array( 'pin', 'unpin', 'feature', 'unfeature', 'lock', 'unlock' ) ) )
		{
			$val = TRUE;
			switch ( $action )
			{
				case 'unpin':
					$val = FALSE;
				case 'pin':
					$column = static::$databaseColumnMap['pinned'];
					break;
				
				case 'unfeature':
					$val = FALSE;
				case 'feature':
					$column = static::$databaseColumnMap['featured'];
					break;
				
				case 'unlock':
					$val = FALSE;
				case 'lock':
					if ( isset( static::$databaseColumnMap['locked'] ) )
					{
						$column = static::$databaseColumnMap['locked'];
					}
					else
					{
						$val = $val ? 'closed' : 'open';
						$column = static::$databaseColumnMap['status'];
					}
					break;
			}
			$this->$column = $val;
			$this->save();

			if ( $action === 'pin' or $action === 'unpin' )
			{
				$dataLayerEvent = $action === 'pin' ? 'pin' : 'unpin';
			}

            /* Fire events for these here, the others will be fired at a later point */
            Event::fire( 'onStatusChange', $this, array( $action ) );
		}
		
		/* Hide is a tiny bit more complicated */
		elseif ( $action === 'hide' )
		{
			$dataLayerEvent = 'hide';
			$this->hide( $member, $reason );
		}
		elseif ( $action === 'unhide' )
		{
			$dataLayerEvent = 'unhide';
			$this->unhide( $member );
		}
		
		/* Delete is just a method */
		elseif ( $action === 'delete' )
		{
			/* If we are retaining content for a period of time, we need to just hide it instead for deleting later - this only works, though, with items that implement \IPS\Content\Hideable */
			if ( Settings::i()->dellog_retention_period AND IPS::classUsesTrait( $this, 'IPS\Content\Hideable' ) AND $immediately === FALSE )
			{
				$this->logDelete( $member );
				return;
			}

			$dataLayerEvent = 'delete';
			$this->delete();
		}
		
		/* Restore is just a method */
		elseif ( $action === 'restore' )
		{
			$this->restore();
		}
		
		/* Restore As Hidden is just a method */
		elseif ( $action === 'restoreAsHidden' )
		{
			$this->restore( TRUE );
		}

		/* Publish is just a method */
		elseif ( $action === 'publish' )
		{
			$this->publish();
		}

		/* Move is just a method */
		elseif ( $action === 'move' )
		{
			$args	= func_get_args();
			if ( $args[2] )
			{
				$this->move( $args[2][0], $args[2][1] );
			}
		}

		if ( $dataLayerEvent and DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_' . $dataLayerEvent ) )
		{
			DataLayer::i()->addEvent( 'content_' . $dataLayerEvent, $this->getDataLayerProperties() );
		}
	}

	/**
	 * @brief	Have we already reported?
	 */
	protected bool|null $alreadyReported = NULL;
	
	/**
	 * Can report?
	 *
	 * @param	Member|NULL	$member	The member to check for (NULL for currently logged in member)
	 * @return	TRUE|string			TRUE or a language string for why not
	 * @note	This requires a few queries, so don't run a check in every template
	 */
	public function canReport( Member|null $member=NULL ): bool|string
	{
		if( !$this->actionEnabled( 'report', $member ) )
		{
			return false;
		}

		$member = $member ?: Member::loggedIn();
		
		/* Is this type of comment reportabe? */
		if ( !( IPS::classUsesTrait( $this, 'IPS\Content\Reportable' ) ) )
		{
			return 'generic_error';
		}
		
		/* Can the member report content? */
		$classToCheck = ( $this instanceof Comment ) ? get_class( $this->item() ) : get_class( $this );

		if ( $member->group['g_can_report'] != '1' AND !in_array( $classToCheck, explode( ',', $member->group['g_can_report'] ) ) )
		{
			return 'no_module_permission';
		}
		
		/* Can they view this? */
		if ( !$this->canView() )
		{
			return 'no_module_permission';
		}

		/* Have they already subitted a report? */
		if( $this->alreadyReported === TRUE )
		{
			return 'report_err_already_reported';
		}
		elseif( $this->alreadyReported === NULL )
		{
			/* Have we already prefetched it? */
			if ( ! isset( $this->reportData ) )
			{
				try
				{
					$idColumn = static::$databaseColumnId;
					$report = Db::i()->select( 'id', 'core_rc_index', array( 'class=? AND content_id=?', get_called_class(), $this->$idColumn ) )->first();
					$this->reportData = Db::i()->select( '*', 'core_rc_reports', array( 'rid=? AND report_by=?', $report, $member->member_id ) )->first();
				}
				catch( UnderflowException $e ){}
			}
			
			/* Check again */
			if ( isset( $this->reportData['date_reported'] ) )
			{
				if ( Settings::i()->automoderation_report_again_mins )
				{
					if ( ( ( time() - $this->reportData['date_reported'] ) / 60 ) > Settings::i()->automoderation_report_again_mins )
					{
						return TRUE;
					}
				}
				
				$this->alreadyReported = TRUE;
				return 'report_err_already_reported';
			}
			
			$this->alreadyReported = FALSE;
		}
		
		return TRUE;
	}

	/**
	 * Can report or revoke report?
	 * This method will return TRUE if the link to report content should be shown (which can occur even if you have already reported if you have permission to revoke your report)
	 *
	 * @param	Member|NULL	$member	The member to check for (NULL for currently logged in member)
	 * @return	bool
	 * @note	This requires a few queries, so don't run a check in every template
	 */
	public function canReportOrRevoke( Member|null $member=NULL ): bool
	{
		/* If we are allowed to report, then we can return TRUE. */
		if( $this->canReport( $member ) === TRUE )
		{
			return TRUE;
		}
		/* If we have already reported but automatic moderation is enabled, show the link so the user can revoke their report. */
		elseif( $this->alreadyReported === TRUE AND Settings::i()->automoderation_enabled )
		{
			return TRUE;
		}

		return FALSE;
	}
	
	/**
	 * Report
	 *
	 * @param	string					$reportContent	Report content message from member
	 * @param	int						$reportType		Report type (see constants in \IPS\core\Reports\Report)
	 * @param	Member|null				$member			Member making the report (or NULL for loggedIn())
	 * @param array $guestDetails Details for a guest report
	 * @return	Report
	 * @throws	UnexpectedValueException	If there is a permission error - you should only call this method after checking canReport
	 */
	public function report( string $reportContent, int $reportType=1, Member|null $member=null, array $guestDetails=array() ): Report
	{
		$member = ( $member ) ?: Member::loggedIn();

		/* Permission check */
		$permCheck = $this->canReport( $member );
		if ( $permCheck !== TRUE )
		{
			throw new UnexpectedValueException( $permCheck );
		}

		/* Find or create an index */
		$idColumn = static::$databaseColumnId;
		$item = ( $this instanceof Comment ) ? $this->item() : $this;
		$itemIdColumn = $item::$databaseColumnId;

		$new = false;
		try
		{
			$index = Report::load( $this->$idColumn, 'content_id', array( 'class=?', get_called_class() ) );
			$index->num_reports = $index->num_reports + 1;
		}
		catch ( OutOfRangeException $e )
		{
			$new = true;
			$index = new Report;
			$index->class = get_called_class();
			$index->content_id = $this->$idColumn;
			$index->perm_id = $this->permId();
			$index->first_report_by = (int) $member->member_id;
			$index->first_report_date = time();
			$index->last_updated = time();
			$index->author = (int) $this->author()->member_id;
			$index->num_reports = 1;
			$index->num_comments = 0;
			$index->auto_moderation_exempt = 0;
			$index->item_id = $item->$itemIdColumn;
			$index->node_id = $item->containerWrapper() ? $item->containerWrapper()->_id : 0;
		}

		/* Only set this to a new report if it is not already under review */
		if( $index->status != 2 )
		{
			$index->status = 1;
		}

		$index->save();

		/* Create a report */
		$reportInsert = array(
			'rid'			=> $index->id,
			'report'		=> $reportContent,
			'report_by'		=> (int) $member->member_id,
			'date_reported'	=> time(),
			'ip_address'	=> Request::i()->ipAddress(),
			'report_type'	=> ( ! $member->member_id and Settings::i()->automoderation_enabled ) ? 0 : $reportType
		);

		if ( count( $guestDetails ) and isset( $guestDetails['email'] ) )
		{
			/* Guest name can be optional */
			$reportInsert['guest_name'] = isset( $guestDetails['name'] ) ? $guestDetails['name'] : '';
			$reportInsert['guest_email'] = $guestDetails['email'];
		}

		$insertID = Db::i()->insert( 'core_rc_reports', $reportInsert );
		$reportInsert['id'] = $insertID;
		
		/* Run automatic moderation */
		$index->runAutomaticModeration();

		/* Send notification to mods */
		$moderators = array( 'm' => array(), 'g' => array() );
		foreach (Db::i()->select( '*', 'core_moderators' ) as $mod )
		{
			$canView = FALSE;
			if ( $mod['perms'] == '*' )
			{
				$canView = TRUE;
			}
			if ( $canView === FALSE )
			{
				$perms = json_decode( $mod['perms'], TRUE );

				if ( isset( $perms['can_view_reports'] ) AND $perms['can_view_reports'] === TRUE )
				{
					$canView = TRUE;
				}

				/* Got nodes? */
				if ( $canView === TRUE and $container = $item->containerWrapper() and isset( $container::$modPerm ) )
				{
					if ( isset( $perms[ $container::$modPerm ] ) and $perms[ $container::$modPerm ] != '*' and $perms[ $container::$modPerm ] != -1 )
					{
						if ( empty( $perms[ $container::$modPerm ] ) or ! in_array( $item->mapped('container'), $perms[ $container::$modPerm ] ) )
						{
							$canView = FALSE;
						}
					}
				}
			}
			if ( $canView === TRUE )
			{
				$moderators[ $mod['type'] ][] = $mod['id'];
			}
		}

		$notification = new Notification( Application::load('core'), 'report_center', $index, array( $index, $reportInsert, $this, $member ) );
		foreach (Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $mem )
		{
			$memberObj = Member::constructFromData( $mem );
			
			/* Members may have individual member level mod permissions, but are also in a group that has moderator permissions. In this case, the member level restrictions always win so we need to recheck those now that we have a member object. See \IPS\Member::modPermissions(). */
			/* @var Member $memberObj */
			$perms = $memberObj->modPermissions();
			$canView = $this->canView( $memberObj );
			if ( $canView === TRUE and $container = $item->containerWrapper() and isset( $container::$modPerm ) )
			{
				if ( isset( $perms[ $container::$modPerm ] ) and $perms[ $container::$modPerm ] != '*' and $perms[ $container::$modPerm ] != -1 )
				{
					if ( empty( $perms[ $container::$modPerm ] ) or ! in_array( $item->mapped('container'), $perms[ $container::$modPerm ] ) )
					{
						$canView = FALSE;
					}
				}
			}
			
			if( $canView === TRUE )
			{
				$notification->recipients->attach( $memberObj );
			}
		}
		$notification->send();
		
		/* Set flag so future calls to report methods return correct value */
		$this->alreadyReported = TRUE;
		$this->reportData = $reportInsert;

		/* If this is new, we need to add a data layer event */
		if ( $index->status === 1 and DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_report' ) )
		{
			$properties = $this->getDataLayerProperties();
			try
			{
				$reportNode =  ( ( $member->member_id or !Settings::i()->automoderation_enabled ) and $reportType > 0 ) ? Types::load( $reportType ) : null;
			}
			catch ( Exception )
			{
				$reportNode = null;
			}

			$properties['report_type_name'] = Lang::load( Lang::defaultLanguage() )->addToStack( $reportNode?->_titleLanguageKey ?: ( $this instanceof Content\Comment ? 'report_message_comment' : 'report_message_item' ) );
			$properties['report_type_id'] = $reportNode ? $reportType : 0;

			Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $properties['report_type_name'] );
			DataLayer::i()->addEvent( 'content_report', DataLayer::i()->filterProperties( $properties ) );
		}

		if( $new )
		{
			Webhook::fire( 'content_reported', $index);

            Event::fire( 'onReport', $this, [ $index ] );
		}

		/* Return */
		return $index;
	}

	/**
	 * Change IP Address
	 * @param string $ip		The new IP address
	 *
	 * @return void
	 */
	public function changeIpAddress( string $ip ): void
	{
		if ( isset( static::$databaseColumnMap['ip_address'] ) )
		{
			$col = static::$databaseColumnMap['ip_address'];
			$this->$col = $ip;
			$this->save();
		}
	}
	
	/**
	 * Change Author
	 *
	 * @param	Member $newAuthor	The new author
	 * @param bool $log		If TRUE, action will be logged to moderator log
	 * @return	void
	 */
	public function changeAuthor( Member $newAuthor, bool $log=TRUE ): void
	{
		$oldAuthor = $this->author();

		/* If we delete a member, then change author, the old author returns 0 as does the new author as the
		   member row is deleted before the task is run */
		if( $newAuthor->member_id and ( $oldAuthor->member_id == $newAuthor->member_id ) )
		{
			return;
		}

		foreach ( array( 'author', 'author_name', 'edit_member_name', 'is_anon' ) as $k )
		{
			if ( isset( static::$databaseColumnMap[ $k ] ) )
			{
				$col = static::$databaseColumnMap[ $k ];
				switch ( $k )
				{
					case 'author':
						$this->$col = $newAuthor->member_id ? $newAuthor->member_id : 0;
						break;
					case 'author_name':
						$this->$col = $newAuthor->member_id ? $newAuthor->name : $newAuthor->real_name;
						break;
					case 'edit_member_name':
						/* Real name will contain the custom guest name if available or '' if not.
						   But we only want to update the user if oldName is the same as the current edit_member_name
						   So if "Bob" edited his own post, you want that to change if Bob becomes Bob2.
						   But if a moderator edited it, we don't want to change that name. */
						if ( $oldAuthor->name == $this->$col )
						{
							$this->$col = $newAuthor->member_id ? $newAuthor->name : $newAuthor->real_name;
						}
						break;
					case 'is_anon':
						/* We are specifying an author so turn off is_anon column */
						if ( $newAuthor->member_id )
						{
							$this->$col = 0;
						}
						break;
				}
			}
		}

		$this->save();

		if ( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation == 'front' and $log )
		{
			Session::i()->modLog( 'modlog__action_changeauthor', array( static::$title => TRUE, $this->url()->__toString() => FALSE, $this->mapped('title') ?: ( method_exists( $this, 'item' ) ? $this->item()->mapped('title') : NULL ) => FALSE ), ( $this instanceof Item ) ? $this : $this->item() );
		}
	}

	/**
	 * Return the filters that are available for selecting table rows
	 *
	 * @return	array
	 */
	public static function getTableFilters(): array
	{
		$return = array();
		
		if ( IPS::classUsesTrait( get_called_class(), 'IPS\Content\Hideable' ) )
		{
			$return[] = 'hidden';
			$return[] = 'unhidden';
			$return[] = 'unapproved';
		}
				
		return $return;
	}
	
	/**
	 * Get content table states
	 *
	 * @return string
	 */
	public function tableStates(): string
	{
		$return	= array();

		if ( IPS::classUsesTrait( $this, 'IPS\Content\Hideable' ) )
		{
			switch ( $this->hidden() )
			{
				case -1:
					$return[] = 'hidden';
					break;
				case 0:
					$return[] = 'unhidden';
					break;
				case 1:
					$return[] = 'unapproved';
					break;
			}
		}
		
		return implode( ' ', $return );
		
	}
	
	/**
	 * Prune IP addresses from content
	 *
	 * @param	int		$days 		Remove from content posted older than DAYS ago
	 * @return	void
	 */
	public static function pruneIpAddresses( int $days=0 ): void
	{
		if ( $days and isset( static::$databaseColumnMap['ip_address'] ) and isset( static::$databaseColumnMap['date'] ) )
		{
			$time = time() - ( 86400 * $days );
			Db::i()->update( static::$databaseTable, array( static::$databasePrefix . static::$databaseColumnMap['ip_address'] => '' ), array( static::$databasePrefix . static::$databaseColumnMap['ip_address'] . "!='' AND " . static::$databasePrefix . static::$databaseColumnMap['date'] . ' <= ' . $time ) );
		}
	}
	
	/**
	 * Get content for an email
	 *
	 * @param Email $email			The email
	 * @param	string		$type			'html' or 'plaintext'
	 * @param	bool		$includeLinks	Whether or not to include links
	 * @param	bool		$includeAuthor	Whether or not to include the author
	 * @return	string
	 */
	public function emailContent( Email $email, string $type, bool $includeLinks=TRUE, bool $includeAuthor=TRUE ): string
	{
		return Email::template( 'core', '_genericContent', $type, array( $this, $includeLinks, $includeAuthor, $email ) );
	}
	
	/**
	 * Get a count of the database table
	 *
	 * @param   bool    $approximate     Accept an approximate result if the table is large (approximate results are faster on large tables)
	 * @return  int
	 */
	public static function databaseTableCount( bool $approximate=FALSE ): int
	{
		$key = 'tbl_cnt_' . ( $approximate ? 'approx_' : 'accurate_' ) . static::$databaseTable;
		$fetchAgain = FALSE;
		
		if ( ! isset( Data\Store::i()->$key ) )
		{
			$fetchAgain = TRUE;
		}
		else
		{
			/* Just check daily */
			$data = Data\Store::i()->$key;
			
			if ( $data['time'] < time() - 86400 )
			{
				$fetchAgain = TRUE;
			}
		}

		if ( $fetchAgain )
		{
			$count = 0;
			/* Accept approximate result? */
			if ( $approximate )
			{
				$approxRows = Db::i()->query( "SHOW TABLE STATUS LIKE '" . Db::i()->prefix . static::$databaseTable . "';" )->fetch_assoc();
				$count = (int) $approxRows['Rows'];
			}

			/* If the table is a reasonable size, we'll get the real value instead */
			if ( $count < 1000000 )
			{
				$count = Db::i()->select( 'COUNT(*)', static::$databaseTable )->first();
			}
			Data\Store::i()->$key = array( 'time' => time(), 'count' => $count );
		}

		$data = Data\Store::i()->$key;
		return $data['count'];
	}
	
	/* !Follow */

	/**
	 * @brief	Follow publicly
	 */
	const FOLLOW_PUBLIC = 1;

	/**
	 * @brief	Follow anonymously
	 */
	const FOLLOW_ANONYMOUS = 2;

	/**
	 * @brief	Number of notifications to process per batch
	 */
	const NOTIFICATIONS_PER_BATCH = NOTIFICATIONS_PER_BATCH;
	
	/**
	 * Send notifications
	 *
	 * @return	void
	 */
	public function sendNotifications(): void
	{
		/* Check the bridge */
		if ( ! Bridge::i()->sendNotifications( $this ) )
		{
			return;
		}

		/* Send quote and mention notifications */
		$sentTo = $this->sendQuoteAndMentionNotifications();

		/* Stop here if the content does not use the "followable" trait */
		if( ( $this instanceof Item and !IPS::classUsesTrait( $this, Followable::class ) ) or ( $this instanceof Comment and !IPS::classUsesTrait( $this->item(), Followable::class ) ) )
		{
			return;
		}

		/* How many followers? */
		try
		{
			if( $this instanceof Comment )
			{
				$count = $this->item()->notificationRecipientsForComments( null, true, $this );
			}
			else
			{
				$count = $this->notificationRecipients( NULL, TRUE );
			}
		}
		catch ( BadMethodCallException $e )
		{
			return;
		}
		
		/* Queue if there's lots, or just send them */
		if ( $count > NOTIFICATION_BACKGROUND_THRESHOLD)
		{
			$idColumn = $this::$databaseColumnId;
			Task::queue( 'core', 'Follow', array( 'class' => get_class( $this ), 'item' => $this->$idColumn, 'sentTo' => $sentTo, 'followerCount' => $count ), 2 );
		}
		else
		{
			$this->sendNotificationsBatch( 0, $sentTo );
		}
	}
	
	/**
	 * Send notifications batch
	 *
	 * @param	int				$offset		Current offset
	 * @param	array			$sentTo		Members who have already received a notification and how - e.g. array( 1 => array( 'inline', 'email' )
	 * @param	string|NULL		$extra		Additional data
	 * @return	int|null		New offset or NULL if complete
	 */
	public function sendNotificationsBatch( int $offset=0, array &$sentTo=array(), string|null $extra=NULL ): int|NULL
	{
		/* Stop here if the content does not use the "followable" trait */
		if( ( $this instanceof Item and !IPS::classUsesTrait( $this, Followable::class ) ) or ( $this instanceof Comment and !IPS::classUsesTrait( $this->item(), Followable::class ) ) )
		{
			return null;
		}

		/* Check authors spam status */
		if( $this->author()->members_bitoptions['bw_is_spammer'] )
		{
			/* Author is flagged as spammer, don't send notifications */
			return NULL;
		}

		$followIds = array();
		$followers = ( $this instanceof Comment ) ?
			$this->item()->notificationRecipientsForComments( array( $offset, static::NOTIFICATIONS_PER_BATCH ), false, $this ) :
			$this->notificationRecipients( array( $offset, static::NOTIFICATIONS_PER_BATCH ) );
		
		/* If $followers is NULL (which can be the case if the follows are just people following the author), just return as there is nothing to do */
		if ( $followers === NULL )
		{
			return NULL;
		}
		
		/* If we're still here, our iterator may not necessarily be one that implements Countable so we need to convert it to an array. */
		$followers = iterator_to_array( $followers );
		
		if( !count( $followers ) )
		{
			return NULL;
		}

		/* Send notification */
		$notification = ( $this instanceof Comment ) ? $this->item()->createNotification( $extra, $this ) : $this->createNotification( $extra );
		$notification->unsubscribeType = 'follow';
		foreach ( $followers as $follower )
		{
			$member = Member::load( $follower['follow_member_id'] );
			if ( $member !== $this->author() and $this->canView( $member ) )
			{
				$followIds[] = $follower['follow_id'];
				$notification->recipients->attach( $member, $follower );
			}
		}

		/* Log that we sent it */
		if( count( $followIds ) )
		{
			Db::i()->update( 'core_follow', array( 'follow_notify_sent' => time() ), Db::i()->in( 'follow_id', $followIds ) );
		}

		$sentTo = $notification->send( $sentTo );
		
		/* Update the queue */
		return $offset + static::NOTIFICATIONS_PER_BATCH;
	}
	
	/**
	 * Send the notifications after the content has been edited (for any new quotes or mentiones)
	 *
	 * @param	string	$oldContent	The content before the edit
	 * @return	void
	 */
	public function sendAfterEditNotifications( string $oldContent ): void
	{				
		$existingData = static::_getQuoteAndMentionIdsFromContent( $oldContent );
		$this->sendQuoteAndMentionNotifications( array_unique( array_merge( $existingData['quotes'], $existingData['mentions'], $existingData['embeds'] ) ) );
	}
		
	/**
	 * Send quote and mention notifications
	 *
	 * @param	array	$exclude		An array of member IDs *not* to send notifications to
	 * @return	array	The members that were notified and how they were notified
	 */
	protected function sendQuoteAndMentionNotifications( array $exclude=array() ): array
	{
		return $this->_sendQuoteAndMentionNotifications( static::_getQuoteAndMentionIdsFromContent( $this->content() ), $exclude );
	}
	
	/**
	 * Send quote and mention notifications from data
	 *
	 * @param	array	$data		array( 'quotes' => array( ... member IDs ... ), 'mentions' => array( ... member IDs ... ), 'embeds' => array( ... member IDs ... ) )
	 * @param	array	$exclude	An array of member IDs *not* to send notifications to
	 * @return	array	The members that were notified and how they were notified
	 */
	protected function _sendQuoteAndMentionNotifications( array $data, array $exclude=array() ): array
	{
		/* Init */
		$sentTo = array();
		
		/* Quotes */
		$data['quotes'] = array_filter( $data['quotes'], function( $v ) use ( $exclude )
		{
			return !in_array( $v, $exclude );
		} );
		if ( !empty( $data['quotes'] ) )
		{
			$notification = new Notification( Application::load( 'core' ), 'quote', $this, array( $this ), array( $this->author()->member_id ) );
			foreach ( $data['quotes'] as $quote )
			{
				$member = Member::load( $quote );
				if ( $member->member_id and $member !== $this->author() and $this->canView( $member ) and !$member->isIgnoring( $this->author(), 'posts' ) )
				{
					$notification->recipients->attach( $member );
				}
			}
			$sentTo = $notification->send( $sentTo );
		}
		
		/* Mentions */
		$data['mentions'] = array_filter( $data['mentions'], function( $v ) use ( $exclude )
		{
			return !in_array( $v, $exclude );
		} );
		if ( !empty( $data['mentions'] ) )
		{
			$notification = new Notification( Application::load( 'core' ), 'mention', $this, array( $this ), array( $this->author()->member_id ) );
			foreach ( $data['mentions'] as $mention )
			{
				$member = Member::load( $mention );
				if ( $member->member_id AND $member !== $this->author() and $this->canView( $member ) and !$member->isIgnoring( $this->author(), 'mentions' ) )
				{
					$notification->recipients->attach( $member );
				}
			}
			$sentTo = $notification->send( $sentTo );
		}

		/* Embeds */
		$data['embeds'] = array_filter( $data['embeds'], function( $v ) use ( $exclude )
		{
			return !in_array( $v, $exclude );
		} );
		if ( !empty( $data['embeds'] ) )
		{
			$notification = new Notification( Application::load( 'core' ), 'embed', $this, array( $this ), array( $this->author()->member_id ) );
			foreach ( $data['embeds'] as $embed )
			{
				$member = Member::load( $embed );
				if ( $member->member_id AND $member !== $this->author() and $this->canView( $member ) and !$member->isIgnoring( $this->author(), 'posts' ) )
				{
					$notification->recipients->attach( $member );
				}
			}
			$sentTo = $notification->send( $sentTo );
		}
	
		/* Return */
		return $sentTo;
	}
	
	/**
	 * Get quote and mention notifications
	 *
	 * @param	string|null	$content	The content
	 * @return	array	array( 'quotes' => array( ... member IDs ... ), 'mentions' => array( ... member IDs ... ), 'embeds' => array( ... member IDs ... )  )
	 */
	protected static function _getQuoteAndMentionIdsFromContent( ?string $content ): array
	{
		$return = array( 'quotes' => array(), 'mentions' => array(), 'embeds' => array() );

		/* If we have no content, don't bother with this */
		if( empty( $content ) )
		{
			return $return;
		}
		
		$document = new DOMDocument( '1.0', 'UTF-8' );
		if ( @$document->loadHTML( DOMDocument::wrapHtml( '<div>' . $content . '</div>' ) ) !== FALSE )
		{
			/* Quotes */
			foreach( $document->getElementsByTagName('blockquote') as $quote )
			{
				if ( $quote->getAttribute('data-ipsquote-userid') and (int) $quote->getAttribute('data-ipsquote-userid') > 0 )
				{
					$return['quotes'][] = $quote->getAttribute('data-ipsquote-userid');
				}
			}
			
			/* Mentions */
			foreach( $document->getElementsByTagName('a') as $link )
			{
				if ( $link->getAttribute('data-mentionid') )
				{					
					if ( !preg_match( '/\/blockquote(\[\d*])?\//', $link->getNodePath() ) )
					{
						$return['mentions'][] = $link->getAttribute('data-mentionid');
					}
				}
			}

			/* Embeds */
			foreach( $document->getElementsByTagName('iframe') as $embed )
			{
				if ( $embed->getAttribute('data-embedauthorid') )
				{
					if ( $embed->getAttribute('data-embedauthorid') and (int) $embed->getAttribute('data-embedauthorid') > 0 and !preg_match( '/\/blockquote(\[\d*])?\//', $embed->getNodePath() ) )
					{
						$return['embeds'][] = $embed->getAttribute('data-embedauthorid');
					}
				}
			}
		}
		
		return $return;
	}
	
	/**
	 * Expire appropriate widget caches automatically
	 *
	 * @return void
	 */
	public function expireWidgetCaches(): void
	{
		Widget::deleteCaches( NULL, static::$application );
	}

	/**
	 * Update "currently viewing" session data after moderator actions that invalidate that data for other users
	 *
	 * @return void
	 */
	public function adjustSessions(): void
	{
		if( $this instanceof Comment )
		{
			try
			{
				$item = $this->item();
			}
			catch( OutOfRangeException $e )
			{
				return;
			}
		}
		else
		{
			$item = $this;
		}

		/** Item::url() can throw a LogicException exception in specific cases like when a Pages Record has no valid page */
		try
		{
			/* We have to send a limit even though we want all records because otherwise the Database store does not return all columns */
			foreach( Store::i()->getOnlineUsers( 0, 'desc', array( 0, 5000 ), NULL, TRUE ) as $session )
			{
				if( mb_strpos( $session['location_url'], (string) $item->url() ) === 0 )
				{
					$sessionData = $session;
					$sessionData['location_url']			= NULL;
					$sessionData['location_lang']			= NULL;
					$sessionData['location_data']			= json_encode( array() );
					$sessionData['current_id']				= 0;
					$sessionData['location_permissions']	= 0;

					Store::i()->updateSession( $sessionData );
				}
			}
		}
		catch( LogicException $e ){}


	}

	/**
	 * Fetch classes from content router
	 *
	 * @param	bool|Member $member		Check member access
	 * @param	bool				$archived	Include any supported archive classes
	 * @param	bool				$onlyItems	Only include item classes
	 * @return	array
	 */
	public static function routedClasses( bool|Member $member=FALSE, bool $archived=FALSE, bool $onlyItems=FALSE ): array
	{
		$classes	= array();

		foreach (Application::allExtensions( 'core', 'ContentRouter', $member, NULL, NULL, TRUE ) as $router )
		{
			foreach ( $router->classes as $class )
			{
				$classes[]	= $class;

				if( $onlyItems )
				{
					continue;
				}
				
				if ( !( $member instanceof Member) )
				{
					$member = $member ? Member::loggedIn() : NULL;
				}
				
				if ( isset( $class::$commentClass ) and $class::supportsComments( $member ) )
				{
					$classes[]	= $class::$commentClass;
				}

				if ( isset( $class::$reviewClass ) and $class::supportsReviews( $member ) )
				{
					$classes[]	= $class::$reviewClass;
				}

				if( $archived === TRUE AND isset( $class::$archiveClass ) )
				{
					$classes[]	= $class::$archiveClass;
				}
			}
		}

		return $classes;
	}

	/**
	 * Override the HTML parsing enabled flag for rebuilds?
	 *
	 * @note	By default this will return FALSE, but classes can override
	 * @see		\IPS\forums\Topic\Post
	 * @return	bool
	 */
	public function htmlParsingEnforced(): bool
	{
		return FALSE;
	}

	/**
	 * Return any custom multimod actions this content item supports
	 *
	 * @return	array
	 */
	public function customMultimodActions(): array
	{
		return array();
	}

	/**
	 * Return any available custom multimod actions this content item class supports
	 *
	 * @note	Return in format of EITHER
	 *	@li	array( array( 'action' => ..., 'icon' => ..., 'label' => ... ), ... )
	 *	@li	array( array( 'grouplabel' => ..., 'icon' => ..., 'groupaction' => ..., 'action' => array( array( 'action' => ..., 'label' => ... ), ... ) ) )
	 * @note	For an example, look at \IPS\core\Announcements\Announcement
	 * @return	array
	 */
	public static function availableCustomMultimodActions(): array
	{
		return array();
	}

	/**
	 * Indefinite Article
	 *
	 * @param Lang|null $lang The language to use, or NULL for the language of the currently logged in member
	 * @param array $options
	 * @return    string
	 */
	public function indefiniteArticle( ?Lang $lang = NULL, array $options=array() ): string
	{
		$container = ( $this instanceof Comment ) ? $this->item()->containerWrapper() : $this->containerWrapper();
		return static::_indefiniteArticle( $container ? $container->_data : array(), $lang, $options );
	}

	/**
	 * Indefinite Article
	 *
	 * @param array|null $containerData Container data
	 * @param Lang|null $lang The language to use, or NULL for the language of the currently logged in member
	 * @param array $options
	 * @return    string
	 */
	public static function _indefiniteArticle( ?array $containerData = NULL, ?Lang $lang = NULL, array $options=array() ): string
	{
		$lang = $lang ?: Member::loggedIn()->language();
		return $lang->addToStack( '__indefart_' . static::$title, FALSE, $options );
	}

	/**
	 * Definite Article
	 *
	 * @param Lang|null $lang The language to use, or NULL for the language of the currently logged in member
	 * @param integer|boolean $count Number of items. If not FALSE, pluralized version of phrase will be used
	 * @return    string
	 */
	public function definiteArticle( ?Lang $lang = NULL, int|bool $count = FALSE ): string
	{
		$container = ( $this instanceof Comment ) ? $this->item()->containerWrapper() : $this->containerWrapper();
		return static::_definiteArticle( $container ? $container->_data : array(), $lang, array(), $count );
	}

	/**
	 * Definite Article
	 *
	 * @param array|null $containerData Basic data about the container. Only includes columns returned by container::basicDataColumns()
	 * @param Lang|null $lang The language to use, or NULL for the language of the currently logged in member
	 * @param array $options Options to pass to \IPS\Lang::addToStack
	 * @param integer|boolean $count Number of items. If not false, pluralized version of phrase will be used.
	 * @return    string
	 */
	public static function _definiteArticle( ?array $containerData = NULL, ?Lang $lang = NULL, array $options = array(), int|bool $count = FALSE ): string
	{
		$lang = $lang ?: Member::loggedIn()->language();
		
		if( ( is_int( $count ) || $count === TRUE ) && $lang->checkKeyExists('__defart_' . static::$title . '_plural') )
		{
			/* If $count is TRUE, use the pluralized form but don't pluralize here - useful if we're passing to JS for example */
			if( is_int( $count ) )
			{
				$options['pluralize'] = array( $count );
			}

			return $lang->addToStack( '__defart_' . static::$title . '_plural', FALSE, $options );
		}

		return $lang->addToStack( '__defart_' . static::$title, FALSE, $options );
	}

	/**
	 * Get preview image for share services
	 *
	 * @return	string
	 */
	public function shareImage(): string
	{
		/* While we now allow multiple share logos now, this deprecated method can only return one */
		$shareLogos = Settings::i()->icons_sharer_logo ? json_decode( Settings::i()->icons_sharer_logo, true ) : array();

		if( count( $shareLogos ) )
		{
			try
			{
				$url = Url::createFromString( File::get( 'core_Icons', array_shift( $shareLogos ) ) );
				return (string) ( $url->setScheme( ( Request::i()->isSecure() ) ? 'https' : 'http' ) );
			}
			catch( Exception $e )
			{
				return '';
			}
		}

		return '';
	}

	/**
	 * Log keyword usage, if any
	 *
	 * @param	string		$content	Content/text of submission
	 * @param	string|NULL	$title		Title of submission
	 * @param	int|NULL			$date		Date of submission
	 * @return	void
	 */
	public function checkKeywords( string $content, string|null $title=null, ?int $date = null ): void
	{
		/* Do we have any keywords to track? */
		if( !Settings::i()->stats_keywords )
		{
			return;
		}

		/* We need to know the ID */
		$idColumn	= static::$databaseColumnId;

		/* If this is a content item and first comment is required, skip checking the comment */
		if ( $this instanceof Comment )
		{
			$itemClass = static::$itemClass;

			if( $itemClass::$firstCommentRequired === TRUE )
			{
				/* During initial post, at this point the firstCommentIdColumn value won't be set, so we check for that or explicitly if this is the first post */
				if( !$this->item()->mapped('first_comment_id') OR $this->$idColumn == $this->item()->mapped('first_comment_id') )
				{
					return;
				}
			}
		}

		$words = preg_split("/[\s]+/", trim( strip_tags( preg_replace( "/<br( \/)?>/", "\n", $content ) ) ), NULL, PREG_SPLIT_NO_EMPTY );

		if( $title !== NULL )
		{
			$titleWords = explode( ' ', $title );
			$words		= array_merge( $words, $titleWords );
		}

		$words = array_unique( $words );

		$keywords = json_decode( Settings::i()->stats_keywords, true );

		$extraData	= json_encode( array( 'class' => get_class( $this ), 'id' => $this->$idColumn ) );

		foreach( $keywords as $keyword )
		{
			if( in_array( $keyword, $words ) )
			{
				$date = $date ?: DateTime::create()->getTimestamp();
				Db::i()->insert( 'core_statistics', array( 'time' => $date, 'type' => 'keyword', 'value_4' => $keyword, 'extra_data' => $extraData ) );
			}
		}
	}

	/**
	 * Return size and downloads count when this content type is inserted as an attachment via the "Insert other media" button on an editor.
	 *
	 * @note Most content types do not support this, and those that do will need to override this method to return the appropriate info
	 * @return array
	 */
	public function getAttachmentInfo(): array
	{
		return array();
	}

	/**
	 * Create a query to fetch the "top members"
	 *
	 * @note	The intention is to formulate a query that will fetch the members with the most contributions
	 * @param	int		$limit	The number of members to return
	 * @return	Select
	 */
	public static function topMembersQuery( int $limit ): Select
	{
		$contentWhere = array( array( static::$databasePrefix . static::$databaseColumnMap['author'] . '<>?', 0 ) );
		if ( isset( static::$databaseColumnMap['hidden'] ) )
		{
			$contentWhere[] = array( static::$databasePrefix . static::$databaseColumnMap['hidden'] . '=0' );
		}
		else if ( isset( static::$databaseColumnMap['approved'] ) )
		{
			$contentWhere[] = array( static::$databasePrefix . static::$databaseColumnMap['approved'] . '=1' );
		}
		
		$authorField = static::$databasePrefix . static::$databaseColumnMap['author'];

		return Db::i()->select( 'COUNT(*) as count, ' . static::$databaseTable . '.' . $authorField, static::$databaseTable, $contentWhere, 'count DESC', $limit, $authorField );
	}
	
	/**
	 * Webhook filters
	 *
	 * @return	array
	 */
	public function webhookFilters(): array
	{
		$filters = array();
		$filters['author'] = $this->author()->member_id;

		/* All our Zapier Triggers for content have a hidden setting, so we're using this one global for items and comments */
		$filters['hidden'] = IPS::classUsesTrait( $this, 'IPS\Content\Hideable' ) && $this->hidden();

		return $filters;
	}

	/**
	 * Get content for embed
	 *
	 * @param	array	$params	Additional parameters to add to URL
	 * @return	string
	 * @throws BadMethodCallException
	 */
	public function embedContent( array $params ): string
	{
		if( !in_array( 'IPS\Content\Embeddable', class_implements( $this ) ) )
		{
			return "";
		}

		if ( $this instanceof Comment )
		{
			$item = $this->item();
			$itemCommentClass = $item::$commentClass;
			$idField = static::$databaseColumnId;

			if ( $this instanceof $itemCommentClass and $item::$firstCommentRequired and ( in_array( 'IPS\Content\Embeddable', class_implements( $item ) ) ) and $item->firstComment()->$idField == $this->$idField )
			{
				if ( @$params['do'] === 'findComment' or @$params['embedDo'] === 'findComment' )
				{
					unset( $params['do'] );
					unset( $params['embedDo'] );
				}
				unset( $params['comment'] );
				unset( $params['embedComment'] );

				return $item->embedContent( $params ); // recurse up to the parent
			}
		}

		$template = Theme::i()->getTemplate( 'global', 'core' );
		return match( true ) {
			$this instanceof Review		=> $template->embedReview( $this->item(), $this, $this->url()->setQueryString( $params ), $this->embedImage() ),
			$this instanceof Comment	=> $template->embedComment( $this->item(), $this, $this->url()->setQueryString( $params ), $this->embedImage() ),
			$this instanceof Item		=> $template->embedItem( $this, $this->url()->setQueryString( $params ), $this->embedImage() ),
			default						=> throw new BadMethodCallException( 'Using class must define embedContent method for non-content classes.' )
		};
	}

	/**
	 * Get image for embed
	 *
	 * @return	File|NULL
	 */
	public function embedImage(): ?File
	{
		if( !in_array( 'IPS\Content\Embeddable', class_implements( $this ) ) )
		{
			return NULL;
		}

		/* Comments/Reviews should use whatever we have in the item */
		if ( $this instanceof Comment )
		{
			return $this->item()->embedImage();
		}

		/* If we have a cover photo, use it */
		return $this->coverPhotoFile();
	}

	/**
	 * Run a UI extension method
	 * This is identical to UiExtension::run, but used to simplify templates
	 *
	 * @param string $method
	 * @param array|null $payload
     * @param bool  $returnAsArray
     * @param string $separator
	 * @return array|string
	 */
	public function ui( string $method, ?array $payload=array(), bool $returnAsArray=false, string $separator = " " ) : array|string
	{
		$response = UiExtension::i()->run( $this, $method, $payload );
        if( !$returnAsArray )
        {
            return implode( $separator, $response );
        }
        return $response;
	}

	/**
	 * Spam methods (not currently used)
	 */
	public function spamProtectionPayload( bool $isEdit, string $contentType, Content $content ): array
	{
		return Bridge::i()->spamProtectionPayload( $isEdit, $contentType, $content );
	}

	public function spamCheck( bool $isEdit, Content $content ): int
	{
		return Bridge::i()->spamCheck( $isEdit, $content );
	}

	public function canMarkAsSpam( $member, Content $content ): bool
	{
		return Bridge::i()->canMarkAsSpam( $member, $content );
	}

	public function canUnmarkAsSpam( $member, Content $content ): bool
	{
		return Bridge::i()->canUnmarkAsSpam( $member, $content );
	}

	/**
	 * Get a url from which to load the mini-profile bar.
	 *
	 * @return string
	 */
	public function get_authorMiniProfileUrl() : string
	{
		$params = [
			'app' => 'core',
			'module' => 'system',
			'controller' => 'ajax',
			'do'			=> 'miniProfile',
			'authorId'		=> $this->author()?->member_id ?? null,
		];

		if ( !IPS::classUsesTrait( $this, Anonymous::class ) or $this->isAnonymous() )
		{
			$params['anonymous'] = '1';
		}

		$item = $this instanceof Comment ? $this->item() : $this;
		if ( IPS::classUsesTrait( $item, Solvable::class ) )
		{
			if ( isset( $this->author_solved_count ) and is_int( $this->author_solved_count ) )
			{
				$params['solvedCount'] = $this->author_solved_count;
			}
			else
			{
				$params['solvedCount'] = 'load';
			}
		}

		$url = Url::internal( '', 'front' )->setQueryString( $params );

		return (string) $url;
	}

	/**
	 * Check whether an event is active for this content type based on the event key. For example, reports don't fire any content_* or comment_* events. This uses the $_bypassDataLayerEvents static property
	 *
	 * @see Content::$_bypassDataLayerEvents
	 *
	 * @param string $eventKey      The data layer event key.
	 *
	 * @return bool
	 */
	public static function dataLayerEventActive( string $eventKey ) : bool
	{
		return empty( static::$_bypassDataLayerEvents ) or ( is_array( static::$_bypassDataLayerEvents ) and !in_array( $eventKey, static::$_bypassDataLayerEvents ) );
	}
}