<?php
/**
 * @brief		Hidebale Trait for Content Models/Comments
 * @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		6 Nov 2013
 */

namespace IPS\Content;

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

use BadMethodCallException;
use DateInvalidOperationException;
use Exception;
use IPS\Api\Webhook;
use IPS\Application;
use IPS\Content\Search\Index;
use IPS\Content\Search\SearchContent;
use IPS\core\Approval;
use IPS\core\DeletionLog;
use IPS\core\IndexNow;
use IPS\core\Profanity;
use IPS\DateTime as IPSDateTime;
use IPS\Db;
use IPS\Events\Event;
use IPS\IPS;
use IPS\Login;
use IPS\Member;
use IPS\Node\Model;
use IPS\Notification;
use IPS\Platform\Bridge;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use RuntimeException;
use UnderflowException;
use function count;
use function defined;
use function get_called_class;
use function get_class;
use function in_array;
use function intval;
use function is_array;
use function substr;
use function ucfirst;

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

/**
 * Hideable Trait for Content Models/Comments
 *
 * @note	Content classes will gain special functionality by implementing this interface
 */
trait Hideable
{
	/**
	 * Content is hidden?
	 *
	 * @return	int
	 *	@li -3 is a post made by a guest using the "post before register" feature
	 *	@li -2 is pending deletion
	 * 	@li	-1 is hidden having been hidden by a moderator
	 * 	@li	0 is unhidden
	 *	@li	1 is hidden needing approval
	 *  @li 2 is hidden because the parent is hidden, but it is not hidden in itself. This is only applicable to comments and reviews, and only when the item is hidden with a value of -1. This value will not be returned if the item itself is hidden with a value of -1, but will be returned if the parent item is hidden with a value of -1.
	 * @note	The actual column may also contain 2 which means the item is hidden because the parent is hidden, but it is not hidden in itself. This method will return -1 in that case.
	 *
	 * @note    A piece of content (item and comment) can have an alias for hidden OR approved.
	 *          With hidden: 0=not hidden, 1=hidden (needs moderator approval), -1=hidden by moderator, 2=parent item is hidden, -2=pending deletion, -3=guest post before register
	 *          With approved: 1=not hidden, 0=hidden (needs moderator approval), -1=hidden by moderator, -2=pending deletion, -3=guest post before register
	 *
	 *          User posting has moderator approval set: When adding an unapproved ITEM (approved=0, hidden=1) you should *not* increment container()->_comments but you should update container()->_unapprovedItems
	 *          User posting has moderator approval set: When adding an unapproved COMMENT (approved=0, hidden=1) you should *not* increment item()->num_comments in item or container()->_comments but you should update item()->unapproved_comments and container()->_unapprovedComments
	 *
	 *          User post is hidden by moderator (approved=-1, hidden=0) you should decrement item()->num_comments and decrement container()->_comments but *not* increment item()->unapproved_comments or container()->_unapprovedComments
	 *          User item is hidden by a moderator (approved=-1, hidden=0) you should decrement container()->comments and subtract comment count from container()->_comments, but *not* increment container()->_unapprovedComments
	 *
	 *          Moderator hides item (approved=-1, hidden=-1) you should substract num_comments from container()->_comments. Comments inside item are flagged as approved=-1, hidden=2 but item()->num_comments should not be substracted from
	 *
	 *          Comments with a hidden value of 2 should increase item()->num_comments but not container()->_comments
	 * @throws	RuntimeException
	 */
	public function hiddenStatus(): int
	{
		if ( isset( static::$databaseColumnMap['hidden'] ) )
		{
			$column = static::$databaseColumnMap['hidden'];
			return ( $this->$column == 2 ) ? -1 : intval( $this->$column );
		}
		elseif ( isset( static::$databaseColumnMap['approved'] ) )
		{
			$column = static::$databaseColumnMap['approved'];
			if ( $this->$column == -2 or $this->$column == -3 )
			{
				return intval( $this->$column );
			}
			return $this->$column == -1 ? intval( $this->$column ) : intval( !$this->$column );
		}
		else
		{
			throw new RuntimeException;
		}
	}
	
	/**
	 * Log for deletion later
	 *
	 * @param	Member|false|null 	$member	The member, NULL for currently logged in, or FALSE for no member
	 * @return	void
	 */
	public function logDelete( Member|false|null $member = NULL ): void
	{
		if( $member === NULL )
		{
			$member = Member::loggedIn();
		}

		$originalState = $this->hidden();
		
		/* Log it! */
		$log = new DeletionLog;
		$log->setContentAndMember( $this, $member );
		$log->save();

		$column = static::$databaseColumnMap[ 'hidden' ] ?? static::$databaseColumnMap[ 'approved' ];
		$this->$column = -2;
		$this->save();

		$item = ( $this instanceof Comment ) ? $this->item() : $this;
		if ( $this instanceof Comment )
		{
			$item = $this->item();

			/* Update last comment stuff */
			$item->resyncLastComment();

			/* Update last review stuff */
			$item->resyncLastReview();

			/* Update number of comments */
			$item->resyncCommentCounts();

			/* Update number of reviews */
			$item->resyncReviewCounts();

			/* Save*/
			$item->save();

			/* Now the container */
			try
			{
				$containerColumn = ( $originalState === 1 ) ? '_unapprovedComments' : '_comments';
				if( $this instanceof Review )
				{
					$containerColumn = ( $originalState === 1 ) ? '_unapprovedReviews' : '_reviews';
				}

				if( $item->container()->$containerColumn !== null )
				{
					$item->container()->$containerColumn = ( $item->container()->$containerColumn > 0 ) ? ( $item->container()->$containerColumn - 1 ) : 0;
					$item->container()->save();
				}
			}
			catch( BadMethodCallException ){}
		}
		else
		{
			/* If this is an item, just update the container counts */
			try
			{
				$containerColumn = ( $originalState === 1 ) ? '_unapprovedItems' : '_items';
				if( $item->container()->$containerColumn !== null )
				{
					$item->container()->$containerColumn = ( $item->container()->$containerColumn > 0 ) ? ( $item->container()->$containerColumn - 1 ) : 0;
					$item->container()->save();
				}
			}
			catch( BadMethodCallException ){}
		}

		if ( IPS::classUsesTrait( $this, 'IPS\Content\Taggable' ) )
		{
			Db::i()->update( 'core_tags_perms', array( 'tag_perm_visible' => 0 ), array( 'tag_perm_aai_lookup=?', $this->tagAAIKey() ) );
		}
		
		try
		{
			$idColumn = static::$databaseColumnId;
			
			if ( $this->container() AND !$this->skipContainerRebuild )
			{
				$this->container()->resetCommentCounts();
				$this->container()->setLastComment();
				$this->container()->setLastReview();
				$this->container()->save();
			}

			/* Update mappings */
			if ( $container = $this->container() and IPS::classUsesTrait( $container, 'IPS\Node\Statistics' ) )
			{
				if ( $this instanceof Comment )
				{
					$container->rebuildPostedIn( array( $this->mapped('item') ) );
				}
				else
				{
					$container->rebuildPostedIn( array($this->$idColumn) );
				}
			}

			try
			{
				$approval = Approval::loadFromContent( get_called_class(), $this->$idColumn );

				/* Items with a required first comment which are deleted while waiting for approval need to have that first comment taken care of as well, because deleting/restoring doesn't touch the first comments state */
				if( $this instanceof Item and static::$firstCommentRequired and $firstComment = $this->firstComment() )
				{
					if( isset( $firstComment::$databaseColumnMap['hidden'] ) )
					{
						$column = $firstComment::$databaseColumnMap['hidden'];
						$firstComment->$column = 0;
						$firstComment->save();
					}
				}

				$approval->delete();
			}
			catch ( \OutOfRangeException )
			{
				/* No approval record exists (or has already been removed); nothing to clean up here. */
			}
		}
		catch( BadMethodCallException|OutOfRangeException ) {}

		/* Fire an event so that we know this was soft-deleted */
		Event::fire( 'onStatusChange', $this, array( 'softDelete' ) );

		if ( $this instanceof Review )
		{
			$this->_recalculateRating();
		}
	}
	
	/**
	 * Restore Content
	 *
	 * @param	bool	$hidden	Restore as hidden?
	 * @return	void
	 */
	public function restore( bool $hidden = FALSE ): void
	{
		try
		{
			$idColumn = static::$databaseColumnId;
			$log = DeletionLog::constructFromData( Db::i()->select( '*', 'core_deletion_log', array( "dellog_content_class=? AND dellog_content_id=?", get_class( $this ), $this->$idColumn ) )->first() );
		}
		catch( UnderflowException )
		{
			/* There's no deletion log record, but this shouldn't stop us from restoring */
		}
		
		/* Restoring as hidden? */
		if ( $hidden )
		{
			$column = static::$databaseColumnMap['hidden'] ?? static::$databaseColumnMap['approved'];
			$this->$column = -1;
		}
		else
		{
			if ( isset( static::$databaseColumnMap['hidden'] ) )
			{
				$column = static::$databaseColumnMap['hidden'];
				$this->$column = 0;
			}
			else if ( isset( static::$databaseColumnMap['approved'] ) )
			{
				$column = static::$databaseColumnMap['approved'];
				$this->$column = 1;
			}
		}

		/* If this is an item with a required first comment, which was deleted while waiting for approval, we need to take care of the first comment too */
		if( $this instanceof Item and static::$firstCommentRequired and $firstComment = $this->firstComment() and $firstComment->hiddenStatus() == -1 and $hidden === FALSE )
		{
			$firstComment->restore( $hidden );
		}
		
		if ( IPS::classUsesTrait( $this, 'IPS\Content\Taggable' ) AND !$hidden )
		{
			Db::i()->update( 'core_tags_perms', array( 'tag_perm_visible' => 1 ), array( 'tag_perm_aai_lookup=?', $this->tagAAIKey() ) );
		}

		/* Save the changes */
		$this->save();

		/* Reindex the now hidden content - if this is a content item with comments or reviews, then make sure to do those too. */
		if ( $this instanceof Item AND ( isset( static::$commentClass ) OR isset( static::$reviewClass ) ) )
		{
			Index::i()->index( ( static::$firstCommentRequired ) ? $this->firstComment() : $this );
			Index::i()->indexSingleItem( $this );
		}
		else
		{
			/* Either this is a comment / review, or the item doesn't support comments or reviews, so we can just reindex it now. */
			Index::i()->index( $this );
		}
		
		/* Delete the log */
		if ( isset( $log ) )
		{
			$log->delete();
		}

		/* Recount the container counters */
		if( $this->container() AND !$this->skipContainerRebuild )
		{
			$this->container()->resetCommentCounts();
			$this->container()->setLastComment();
			$this->container()->setLastReview();
			$this->container()->save();
		}

		/* Fire an event so we know the content was restored */
		Event::fire( 'onStatusChange', $this, array( 'restore' ) );
		
		if ( $this instanceof Review )
		{
			$this->_recalculateRating();
		}
	}
	
	/**
	 * Can restore?*
	 *
	 * @param	Member|NULL	$member	The member, or currently logged in member
	 * @return	bool
	 */
	public function canRestore( ?Member $member=NULL ): bool
	{
		/* Extensions go first */
		if( $permCheck = Permissions::can( 'restore', $this, $member ) )
		{
			return ( $permCheck === Permissions::PERM_ALLOW );
		}

		if( !$this->actionEnabled( 'restore', $member ) )
		{
			return false;
		}

		$member = $member ?: Member::loggedIn();
		return $member->modPermission('can_manage_deleted_content');
	}

	/**
	 * Hide
	 *
	 * @param Member|NULL|FALSE $member The member doing the action (NULL for currently logged in member, FALSE for no member)
	 * @param string|NULL $reason Reason
	 * @return    void
	 * @throws Exception
	 */
	public function hide( Member|null|bool $member, string|null $reason = NULL ): void
	{
		/* Hide any moved links that point to this item - moved_on>? is for query optimisation purposes */
		if( $this instanceof Item and isset( static::$databaseColumnMap['moved_to'] ) and isset( static::$databaseColumnMap['moved_on'] ) )
		{
			$idColumn = static::$databaseColumnId;
			$where = array(
				array( static::$databasePrefix . static::$databaseColumnMap['moved_on'] . '>?', 0 ),
				array( static::$databasePrefix . static::$databaseColumnMap['moved_to'] . " LIKE CONCAT( ?, '%')", $this->$idColumn . '&' )
			);

			if( isset( static::$databaseColumnMap['state'] ) )
			{
				$where[] = array( static::$databasePrefix . static::$databaseColumnMap['state'] . '=?', 'link' );
			}

			foreach( Db::i()->select( '*', static::$databaseTable, $where ) as $link )
			{
				static::constructFromData( $link )->hide( $member, $reason );
			}
		}

		if ( isset( static::$databaseColumnMap['hidden'] ) )
		{
			$column = static::$databaseColumnMap['hidden'];
		}
		elseif ( isset( static::$databaseColumnMap['approved'] ) )
		{
			$column = static::$databaseColumnMap['approved'];
		}
		else
		{
			throw new RuntimeException;
		}

		/* Already hidden? */
		if( $this->$column == -1 )
		{
			return;
		}
		
		if ( $this instanceof Item )
		{
			$idColumn = static::$databaseColumnId;

			Db::i()->delete( 'core_notifications', array( 'item_class=? AND item_id=?', get_class( $this ), (int) $this->$idColumn ) );

			if( SearchContent::isSearchable( $this ) )
			{
				if ( isset( static::$commentClass ) OR isset( static::$reviewClass ) )
				{
					if ( isset( static::$commentClass ) )
					{
						$commentClass = static::$commentClass;
						if ( IPS::classUsesTrait( $commentClass, 'IPS\Content\Hideable' ) AND isset( $commentClass::$databaseColumnMap['hidden'] ) )
						{
							Db::i()->update( $commentClass::$databaseTable, array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] => 2 ), array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['item'] . '=? AND ' . $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] . '=?', $this->$idColumn, 0 ) );
						}
					}
					
					if ( isset( static::$reviewClass ) )
					{
						$reviewClass = static::$reviewClass;
						if ( IPS::classUsesTrait( $reviewClass, 'IPS\Content\Hideable' ) AND isset( $reviewClass::$databaseColumnMap['hidden'] ) )
						{
							Db::i()->update( $reviewClass::$databaseTable, array( $reviewClass::$databasePrefix . $reviewClass::$databaseColumnMap['hidden'] => 2 ), array( $reviewClass::$databasePrefix . $reviewClass::$databaseColumnMap['item'] . '=? AND ' . $reviewClass::$databasePrefix . $reviewClass::$databaseColumnMap['hidden'] . '=?', $this->$idColumn, 0 ) );
						}
					}
					
					$firstComment = NULL;
					if ( static::$firstCommentRequired AND isset( static::$commentClass ) AND $firstComment = $this->firstComment() )
					{
						$commentClass = static::$commentClass;
						if( isset( $commentClass::$databaseColumnMap['hidden'] ) )
						{
							$firstColumn = $commentClass::$databaseColumnMap['hidden'];
							$firstComment->$firstColumn = 2;
						}

						Index::i()->index( $firstComment );
					}
				}
			}
		}

		$this->$column = -1;
		$this->save();
		$this->onHide( $member );
		
		$idColumn = static::$databaseColumnId;
		if ( static::$hideLogKey )
		{
			Db::i()->delete( 'core_soft_delete_log', array( 'sdl_obj_id=? AND sdl_obj_key=?', $this->$idColumn, static::$hideLogKey ) );
			Db::i()->insert( 'core_soft_delete_log', array(
				'sdl_obj_id'		=> $this->$idColumn,
				'sdl_obj_key'		=> static::$hideLogKey,
				'sdl_obj_member_id'	=> $member === FALSE ? 0 : intval( $member ? $member->member_id : Member::loggedIn()->member_id ),
				'sdl_obj_date'		=> time(),
				'sdl_obj_reason'	=> $reason,
				
			) );
		}
		
		if ( IPS::classUsesTrait( $this, 'IPS\Content\Taggable' ) )
		{
			Db::i()->update( 'core_tags_perms', array( 'tag_perm_visible' => 0 ), array( 'tag_perm_aai_lookup=?', $this->tagAAIKey() ) );
		}

		/* Update the solved index */
		$idColumn = static::$databaseColumnId;
		$solvedWhere = array(
			array( 'app=?', static::$application )
		);

		if ( $this instanceof Item )
		{
			$item = $this;
			if( isset( static::$commentClass ) )
			{
				$solvedWhere[] = array( 'comment_class=?', static::$commentClass );
			}

			$solvedWhere[] = array( 'item_id=?', $this->$idColumn );
		}
		else
		{
			$item = $this->item();
			$solvedWhere[] = array( 'comment_class=?', get_called_class() );
			$solvedWhere[] = array( 'comment_id=?', $this->$idColumn );
		}

		Db::i()->update( 'core_solved_index', [ 'hidden' => 1 ], $solvedWhere );

		if( IPS::classUsesTrait( $item, Helpful::class ) )
		{
			$item->recountHelpfuls();
		}

        /* Update search index */
		if( SearchContent::isSearchable( $this ) )
        {
            Index::i()->index( $this );

			if ( $this instanceof Item )
			{
				/* Now re-index all the reviews and comments */
				Index::i()->indexSingleItem( $this );
			}
        }

		/* Clear the item stats */
		if ( IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
		{
			try
			{
				$item->clearCachedStatistics( TRUE );
			}
			catch ( Exception )
			{
				/* Sssh */
			}
		}

		$this->expireWidgetCaches();
		$this->adjustSessions();
		
		try
		{
			Approval::loadFromContent( get_called_class(), $this->$idColumn )->delete();
		}
		catch( OutOfRangeException ) { }
	}
	
	/**
	 * Unhide
	 *
	 * @param	Member|NULL|FALSE	$member	The member doing the action (NULL for currently logged in member, FALSE for no member)
	 * @return	void
	 */
	public function unhide( Member|null|bool $member ): void
	{
		/* Update our comments first - this is so that when onUnhide is called in the parent, then these posts will be accounted for when comment counts are reset */
		if ( $this instanceof Item )
		{
			$idColumn = static::$databaseColumnId;

			/* Unhide any moved links that point to this item - moved_on>? is for query optimisation purposes */
			if( isset( static::$databaseColumnMap['moved_to'] ) and isset( static::$databaseColumnMap['moved_on'] ) )
			{
				$where = array(
					array( static::$databasePrefix . static::$databaseColumnMap['moved_on'] . '>?', 0 ),
					array( static::$databasePrefix . static::$databaseColumnMap['moved_to'] . " LIKE CONCAT( ?, '%')", $this->$idColumn . '&' )
				);

				if( isset( static::$databaseColumnMap['state'] ) )
				{
					$where[] = array( static::$databasePrefix . static::$databaseColumnMap['state'] . '=?', 'link' );
				}

				foreach( Db::i()->select( '*', static::$databaseTable, $where ) as $link )
				{
					static::constructFromData( $link )->unhide( $member );
				}
			}

			foreach ( array( 'commentClass', 'reviewClass' ) as $class )
			{
				if ( isset( static::$$class ) )
				{
					$className = static::$$class;
					if ( IPS::classUsesTrait( $className, 'IPS\Content\Hideable' ) AND isset( $className::$databaseColumnMap['hidden'] ) )
					{
						Db::i()->update( $className::$databaseTable, array( $className::$databasePrefix . $className::$databaseColumnMap['hidden'] => 0 ), array( $className::$databasePrefix . $className::$databaseColumnMap['item'] . '=? AND ' . $className::$databasePrefix . $className::$databaseColumnMap['hidden'] . '=?', $this->$idColumn, 2 ) );
					}
				}
			}
		}

		/* If we're approving, we have to do extra stuff */
		$approving	= FALSE;
		$pbr		= FALSE;
		if ( $this->hidden() === 1 )
		{
			$approving = TRUE;
			if ( isset( static::$databaseColumnMap['approved_by'] ) and $member !== FALSE )
			{
				$column = static::$databaseColumnMap['approved_by'];
				$this->$column = $member ? $member->member_id : Member::loggedIn()->member_id;
			}
			if ( isset( static::$databaseColumnMap['approved_date'] ) )
			{
				$column = static::$databaseColumnMap['approved_date'];
				$this->$column = time();
			}
		}
		elseif( $this->hidden() === -3 )
		{
			$pbr = TRUE;
		}

		/* Now do the actual stuff */
		if ( isset( static::$databaseColumnMap['hidden'] ) )
		{
			$column = static::$databaseColumnMap['hidden'];

			/* Already approved? */
			if( $this->$column == 0 )
			{
				return;
			}

			$this->$column = 0;
		}
		elseif ( isset( static::$databaseColumnMap['approved'] ) )
		{
			$column = static::$databaseColumnMap['approved'];

			/* Already approved? */
			if( $this->$column == 1 )
			{
				return;
			}

			$this->$column = 1;
		}
		else
		{
			throw new RuntimeException;
		}
		$this->save();
		$this->onUnhide( ( $approving OR ( $pbr AND $this->hidden() === 0 ) ), $member );

		/* Update container if needed */
		if( $this instanceof Item )
		{
			try
			{
				if ( $this->container()->_comments !== NULL )
				{
					$this->container()->setLastComment( null, $this );
					$this->container()->save();
				}

				if ( $this->container()->_reviews !== NULL )
				{
					$this->container()->setLastReview();
					$this->container()->save();
				}
			} catch ( BadMethodCallException ) {}
		}
		
		$idColumn = static::$databaseColumnId;
		if ( static::$hideLogKey )
		{
			Db::i()->delete('core_soft_delete_log', array('sdl_obj_id=? AND sdl_obj_key=?', $this->$idColumn, static::$hideLogKey));
		}

		/* And update the tags perm cache */
		if ( IPS::classUsesTrait( $this, 'IPS\Content\Taggable' ) )
		{
			Db::i()->update( 'core_tags_perms', array( 'tag_perm_visible' => 1 ), array( 'tag_perm_aai_lookup=?', $this->tagAAIKey() ) );
		}
		
		/* Update report center stuff */
		if ( IPS::classUsesTrait( $this, 'IPS\Content\Reportable' ) )
		{
			$this->moderated( 'unhide' );
		}
		
		/* Send notifications if necessary */
		if ( ( $approving OR ( $pbr AND $this->hidden() === 0 ) ) )
		{
			$this->sendApprovedNotification();
		}

		/* Award points */
		$itemClass = in_array( 'IPS\Content\Comment', class_parents( get_called_class() ) ) ? static::$itemClass : get_called_class();
		if ( $this instanceof Item )
		{
			$this->author()->achievementAction( 'core', 'NewContentItem', $this );
		}
		elseif ( $this instanceof Review )
		{
			$this->author()->achievementAction( 'core', 'Review', $this );
		}
		elseif ( $this instanceof Comment and ( ( $itemClass::$firstCommentRequired AND ! $this->isFirst() ) OR ( ! $itemClass::$firstCommentRequired ) ) )
		{
			$this->author()->achievementAction( 'core', 'Comment', $this );
		}

		/* Update solved index */
		if ( $this instanceof Item )
		{
			$item = $this;
			if( isset( static::$commentClass ) )
			{
				$solvedWhere[] = array( 'comment_class=?', static::$commentClass );
			}

			$solvedWhere[] = array( 'item_id=?', $this->$idColumn );
		}
		else
		{
			$item = $this->item();
			$solvedWhere[] = array( 'comment_class=?', get_called_class() );
			$solvedWhere[] = array( 'comment_id=?', $this->$idColumn );
		}

		Db::i()->update( 'core_solved_index', [ 'hidden' => 0 ], $solvedWhere );

		if( IPS::classUsesTrait( $item, Helpful::class ) )
		{
			$item->recountHelpfuls();
		}

		/* Clear the item stats */
		if ( IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
		{
			try
			{
				$item->clearCachedStatistics();
			}
			catch ( Exception )
			{
				/* Sssh */
			}
		}
		
		/* Send webhook if necessary */
		if ( $approving )
		{
			Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_create', $this, $this->webhookFilters() );

			if ( $this instanceof Item and $itemClass::$firstCommentRequired === TRUE and $firstComment = $this->firstComment() )
			{
				Webhook::fire( str_replace( '\\', '', substr( get_class( $firstComment ), 3 ) ) . '_create', $firstComment, $firstComment->webhookFilters() );
			}
		}

		$this->expireWidgetCaches();
		$this->adjustSessions();
		
		try
		{
			Approval::loadFromContent( get_called_class(), $this->$idColumn )->delete();
		}
		catch( OutOfRangeException ) { }
		
		if ( $this instanceof Item )
		{
			/* And then update the search index */
			if( SearchContent::isSearchable( $this ) )
			{
				if ( isset( static::$commentClass ) OR isset( static::$reviewClass ) )
				{
					if( isset( static::$databaseColumnMap['state'] ) )
					{
						$stateColumn = static::$databaseColumnMap['state'];
						if ( $this->$stateColumn == 'link' )
						{
							return;
						}
					}
	
					$firstComment = NULL;
					if ( static::$firstCommentRequired AND isset( static::$commentClass ) AND $firstComment = $this->firstComment() )
					{
						$className = static::$commentClass;
						if( isset( $className::$databaseColumnMap['hidden'] ) )
						{
							$column = $className::$databaseColumnMap['hidden'];
							$firstComment->$column = 0;
						}

						Index::i()->index( $firstComment );
					}
				}
			}
		}

		/* Update search index */
		if( SearchContent::isSearchable( $this ) )
		{
			Index::i()->index( $this );

			if ( $this instanceof Item )
			{
				/* Now re-index all the reviews and comments */
				Index::i()->indexSingleItem( $this );
			}
		}
	}

	/**
	 * @brief	Hidden blurb cache
	 */
	protected ?string $hiddenBlurb	= NULL;

	/**
	 * Blurb for when/why/by whom this content was hidden
	 *
	 * @return	string
	 */
	public function hiddenBlurb(): string
	{
		if( $this->hiddenBlurb === NULL )
		{
			try
			{
				$idColumn = static::$databaseColumnId;
				$log = Db::i()->select( '*', 'core_soft_delete_log', array( 'sdl_obj_id=? AND sdl_obj_key=?', $this->$idColumn, static::$hideLogKey ) )->first();
				
				if ( $log['sdl_obj_member_id'] )
				{
					$this->hiddenBlurb = Member::loggedIn()->language()->addToStack('hidden_blurb', FALSE, array( 'sprintf' => array( Member::load( $log['sdl_obj_member_id'] )->name, IPSDateTime::ts( $log['sdl_obj_date'] )->relative(),  $log['sdl_obj_reason'] ?: Member::loggedIn()->language()->addToStack('hidden_no_reason') ) ) );
				}
				else
				{
					$this->hiddenBlurb = Member::loggedIn()->language()->addToStack('hidden_blurb_no_member', FALSE, array( 'sprintf' => array( IPSDateTime::ts( $log['sdl_obj_date'] )->relative(), $log['sdl_obj_reason'] ?: Member::loggedIn()->language()->addToStack('hidden_no_reason') ) ) );
				}
			
			}
			catch ( UnderflowException )
			{
				/* If this is requiring approval and has a logged reason */
				$hidden = $this->hidden();
				$item = NULL;
				if ( ( $this instanceof Comment ) )
				{
					$item = $this->item();
					/* If this is the first comment, and it's required, then we need to check the items hidden status instead */
					if ( $item::$firstCommentRequired AND $this->isFirst() AND $hidden !== 1 )
					{
						$hidden = $this->item()->hidden();
					}
				}

				if ( $item and $hidden === 1 AND IPS::classUsesTrait( $item, 'IPS\Content\Hideable' ) )
				{
					/* If moderator, show the reason */
					$reason = NULL;
					if( $this->modPermission( 'unhide' ) )
					{
						$reason = ( $item and $item::$firstCommentRequired AND $this->isFirst() ) ? $item->approvalQueueReason() : $this->approvalQueueReason();
					}
					
					if ( $reason )
					{
						$this->hiddenBlurb = Member::loggedIn()->language()->addToStack( 'hidden_with_reason', FALSE, array( 'sprintf' => array( $reason ) ) );
					}
					else
					{
						/* Otherwise show a message that the content requires approval before it can be edited. */
						$this->hiddenBlurb = Member::loggedIn()->language()->addToStack( 'hidden_awaiting_approval' );
					}
				}
				else
				{
					$this->hiddenBlurb = Member::loggedIn()->language()->addToStack('hidden');
				}
			}
		}

		return $this->hiddenBlurb;
	}
	
	/**
	 * Blurb for when/why/by whom this content was deleted
	 *
	 * @return	string
	 * @throws BadMethodCallException
	 */
	public function deletedBlurb(): string
	{
		try
		{
			$idColumn = static::$databaseColumnId;
			$log = DeletionLog::constructFromData( Db::i()->select( '*', 'core_deletion_log', array( "dellog_content_class=? AND dellog_content_id=?", get_class( $this ), $this->$idColumn ) )->first() );
			if( $log->_deleted_by )
			{
				return Member::loggedIn()->language()->addToStack( 'deletion_blurb', FALSE, array( 'sprintf' => array( $log->_deleted_by->name, $log->deleted_date->fullYearLocaleDate(), $log->deletion_date->fullYearLocaleDate() ) ) );
			}
			else
			{
				return Member::loggedIn()->language()->addToStack( 'deletion_blurb_no_member', FALSE, array( 'sprintf' => array( $log->deletion_date->fullYearLocaleDate() ) ) );
			}
		}
		catch( UnderflowException )
		{
			return Member::loggedIn()->language()->addToStack('deleted');
		}
	}
	
	/**
	 * @brief	Reason for requiring approval
	 */
	protected ?string $_approvalQueueReason = NULL;
	
	/**
	 * Approval Queue Reason
	 *
	 * @return	NULL|bool|string
	 */
	public function approvalQueueReason(): null|bool|string
	{
		if ( $this->_approvalQueueReason === NULL )
		{
			try
			{
				$idColumn = static::$databaseColumnId;
				$this->_approvalQueueReason = Approval::loadFromContent( get_class( $this ), $this->$idColumn )->reason();
			}
			catch( OutOfRangeException )
			{
				$this->_approvalQueueReason = NULL;
			}
		}
		return $this->_approvalQueueReason;
	}
	
	/**
	 * Get HTML for search result display
	 *
	 * @param	NULL|string		$ref		Referrer
	 * @param	Model	$container	Container
	 * @param	string			$title		Title
	 * @return	callable
	 */
	public function approvalQueueHtml( ?string $ref, Model $container, string $title ): string
	{
		return Theme::i()->getTemplate( 'modcp', 'core', 'front' )->approvalQueueItem( $this, $ref, $container, $title );
	}
	
	/**
	 * Log a row in core_post_before_registering
	 *
	 * @param	string	$guestEmail	Guest email address
	 * @param	string|null	$key		User's existing post_before_register cookie value
	 * @return	string	The new key, if one wasn't provided
	 */
	public function _logPostBeforeRegistering( string $guestEmail, ?string $key = NULL ): string
	{
		$key = $key ?: Login::generateRandomString();
		
		$idColumn = static::$databaseColumnId;
		Db::i()->insert( 'core_post_before_registering', array(
			'email'		=> $guestEmail,
			'class'		=> get_class( $this ),
			'id'		=> $this->$idColumn,
			'timestamp'	=> time(),
			'secret'	=> $key,
			'language'	=> Member::loggedIn()->language()->id
		) );
		
		/* enable followup task */
		Db::i()->update( 'core_tasks', array( 'enabled' => 1 ), "`key`='postBeforeRegisterFollowup'" );
		
		return $key;
	}
	
	/**
	 * Send Approved Notification
	 *
	 * @return	void
	 */
	public function sendApprovedNotification(): void
	{
		$this->sendNotifications();
		$this->sendAuthorApprovalNotification();
	}

	/**
	 * Send Author Approval Notification
	 *
	 * @return  void
	 */
	public function sendAuthorApprovalNotification(): void
	{
		/* Tell the author their content has been approved */
		$member = Member::load( $this->mapped('author') );
		$notification = new Notification( Application::load('core'), 'approved_content', $this, array( $this ), array(), FALSE );
		$notification->recipients->attach( $member );
		$notification->send();
	}

	/**
	 * Send Unapproved Notification
	 *
	 * @return    void
	 * @throws DateInvalidOperationException
	 */
	public function sendUnapprovedNotification(): void
	{
		/* Check the bridge */
		if ( ! Bridge::i()->sendUnapprovedNotification( $this ) )
		{
			return;
		}

		$moderators = array( 'g' => array(), 'm' => array() );
		foreach(Db::i()->select( '*', 'core_moderators' ) AS $mod )
		{
			$canView = FALSE;
			$canApprove = FALSE;
			if ( $mod['perms'] == '*' )
			{
				$canView = TRUE;
				$canApprove = TRUE;
			}
			else
			{
				$perms = json_decode( $mod['perms'], TRUE );

				foreach ( array( 'canView' => 'can_view_hidden_', 'canApprove' => 'can_unhide_' ) as $varKey => $modPermKey )
				{
					if ( isset( $perms[ $modPermKey . 'content' ] ) AND $perms[ $modPermKey . 'content' ] )
					{
						$$varKey = TRUE;
					}
					else
					{
						try
						{
							$container = ( $this instanceof Comment ) ? $this->item()->container() : $this->container();
							$containerClass = get_class( $container );
							$title = static::$title;
							if
							(
								isset( $containerClass::$modPerm )
								and
								(
									$perms[ $containerClass::$modPerm ] === -1
									or
									(
										is_array( $perms[ $containerClass::$modPerm ] )
										and
										in_array( $container->_id, $perms[ $containerClass::$modPerm ] )
									)
								)
								and
								isset( $perms[ $modPermKey . $title ] )
								and
								$perms[ $modPermKey . $title ]
							)
							{
								$$varKey = TRUE;
							}
						}
						catch ( BadMethodCallException ) { }
					}
				}
			}
			if ( $canView === TRUE and $canApprove === TRUE )
			{
				$moderators[ $mod['type'] ][] = $mod['id'];
			}
		}
						
		$notification = new Notification( Application::load('core'), 'unapproved_content', $this, array( $this, $this->author() ) );
		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 $member )
		{
			$member = Member::constructFromData( $member );
			/* @var $member Member */
			/* We don't need to notify the author of the content or when member cannot view this content*/
			if( $this->author()->member_id != $member->member_id AND $this->canView( $member ) )
            {
                $notification->recipients->attach( $member );
            }
		}
		$notification->send();
	}

	/**
	 * Check comment against profanity filters but do not act on it
	 *
	 * @param bool $first Is this the first comment?
	 * @param bool $edit Are we editing or merging (true) or is this a new comment (false)?
	 * @param string|null $content The content to check - useful for if the content needs to be checked first, before it gets saved to the database.
	 * @param string|NULL|bool $title The title of the content to check, or NULL to check the current title, or FALSE to not check at all.
	 * @param string|null $autoSaveLocation The autosave location key of any editors to check, or NULL to use the default.
	 * @param array|null $autoSaveKeys The autosave keys (for new content) or attach lookup ids (for an edit) of any editors to check, or NULL to use the default.
	 * @param array $imageUploads Images that have been uploaded that may require moderation
	 * @param bool $hiddenByFilter
	 * @param bool $itemHiddenByFilter
	 * @param array $filtersMatched What matched, passed by reference
	 * @return    bool                                    Whether to send unapproved notifications (i.e. true if the content was hidden)
	 */
	public function shouldTriggerProfanityFilters( bool $first=FALSE, bool $edit=TRUE, ?string $content=NULL, string|null|bool $title=NULL, ?string $autoSaveLocation=NULL, ?array $autoSaveKeys=NULL, array $imageUploads=[], bool &$hiddenByFilter = FALSE, bool &$itemHiddenByFilter = FALSE, array &$filtersMatched = array() ): bool
	{		
		/* Set our content */
		$content = $content ?: $this->content();
				
		/* We need our item */
		$item = $this;
		if ( $this instanceof Comment )
		{
			$item = $this->item();
			
			if ( $item::$firstCommentRequired AND $first AND $title !== FALSE )
			{
				$title = $title ?: $item->mapped('title');
			}
		}
		else
		{
			if ( $title !== FALSE )
			{
				$title = $title ?: $this->mapped('title');
			}
		}
		
		/* If this content is "post before register", skip this check (we'll do it after registration is complete) */
		if ( $this->hidden() === -3 or ( $item::$firstCommentRequired and $first and $item->hidden() === -3 ) )
		{
			return FALSE;
		}

		/* Check the author cannot bypass */
		$member = $this->author();
		if ( $member->group['g_bypass_badwords'] )
		{
			return FALSE;
		}
				
		/* First, check the image scanner */
		$idColumn = static::$databaseColumnId;
		$itemIdColumn = $item::$databaseColumnId;
		if ( Settings::i()->ips_imagescanner_enable )
		{
			if ( $edit )
			{
				$autoSaveLocation = $autoSaveLocation ?: ( $item::$application . '_' . ucfirst( $item::$module ) );
				
				if ( !$autoSaveKeys )
				{			
					if ( $this instanceof Comment and ( !( $this instanceof Review ) or !$first or !$item::$firstCommentRequired ) )
					{
						$autoSaveKeys = [ $this->attachmentIds() ];
					}
					else
					{
						$autoSaveKeys = [ [ $item->$itemIdColumn ] ];
					}
				}
				
				$clauses = [];
				$binds = [];
				foreach ( $autoSaveKeys as $_autoSaveKeys )
				{				
					$idLookups = [];
					foreach ( range( 1, count( $_autoSaveKeys ) ) as $i )
					{
						$idLookups[]= "id$i=?";
					}
					$clauses[] = '( ' . implode( ' AND ', $idLookups ) . ' )';
					$binds = array_merge( $binds, $_autoSaveKeys );
				}
				
				$attachmentsMapWhere = array_merge( array( 'location_key=? AND ( ' . implode( ' OR ', $clauses ) . ' )', $autoSaveLocation ), $binds );
			}
			else
			{
				if ( $autoSaveKeys === NULL )
				{
					if ( $this instanceof Item or ( $first and $item::$firstCommentRequired ) )
					{
						$container = $item->containerWrapper();
						$autoSaveKeys[] = 'newContentItem-' . $item::$application . '/' . $item::$module  . '-' . ( $container ? $container->_id : 0 );
					}
					else
					{
						if ( $this instanceof Review )
						{
							$autoSaveKeys[] = 'review-' . $item::$application . '/' . $item::$module  . '-' . $item->$itemIdColumn;
						}
						else
						{
							$autoSaveKeys[] = 'reply-' . $item::$application . '/' . $item::$module  . '-' . $item->$itemIdColumn;
						}
					}
				}
				
				$attachmentsMapWhere = Db::i()->in( 'temp', array_map( 'md5', $autoSaveKeys ) );
			}

			if ( $this instanceof Item OR ( $first AND $item::$firstCommentRequired ) )
			{
				$itemHiddenByFilter = Profanity::hiddenByFilters( $content, $member, $filtersMatched );

				if ( $title AND !$itemHiddenByFilter )
				{
					$itemHiddenByFilter = Profanity::hiddenByFilters( $title, $member, $filtersMatched );
				}
			}
			else
			{
				$hiddenByFilter = Profanity::hiddenByFilters( $content, $member, $filtersMatched );

				if ( $title AND !$hiddenByFilter )
				{
					$hiddenByFilter = Profanity::hiddenByFilters( $title, $member, $filtersMatched );
				}
			}
			
			foreach (Db::i()->select( 'attach_moderation_status', 'core_attachments', [ 'attach_id IN (?)', Db::i()->select( 'attachment_id', 'core_attachments_map', $attachmentsMapWhere ) ] ) as $attachModerationStatus )
			{
				if ( $attachModerationStatus === 'pending' )
				{
					if ( $this instanceof Item or ( $first and $item::$firstCommentRequired ) )
					{
						$itemHiddenByFilter = TRUE;
					}
					else
					{
						$hiddenByFilter = TRUE;
					}
					break;
				}
			}
			
			if ( !$hiddenByFilter and !$itemHiddenByFilter )
			{
				foreach ( $imageUploads as $file )
				{
					if ( $file->requiresModeration )
					{
						if ( $this instanceof Item or ( $first and $item::$firstCommentRequired ) )
						{
							$itemHiddenByFilter = TRUE;
						}
						else
						{
							$hiddenByFilter = TRUE;
						}
						break;
					}
				}
				
				if ( $hiddenByFilter OR $itemHiddenByFilter )
				{
					Approval::create( $this, 'image' );
				}
			}
		}
		else
		{
			/* Then pass this through our profanity and link filters */
			try
			{
				if ( $this instanceof Item or ( $first and $item::$firstCommentRequired ) )
				{
					if ( !$itemHiddenByFilter )
					{
						$itemHiddenByFilter = Profanity::hiddenByFilters( $content, $member, $filtersMatched );
					}
					if ( !$itemHiddenByFilter and $title )
					{
						$itemHiddenByFilter = Profanity::hiddenByFilters( $title, $member, $filtersMatched );
					}
				}
				else
				{
					if ( !$hiddenByFilter )
					{
						$hiddenByFilter = Profanity::hiddenByFilters( $content, $member, $filtersMatched );
					}
					if ( !$hiddenByFilter and $title )
					{
						$hiddenByFilter = Profanity::hiddenByFilters( $title, $member, $filtersMatched );
					}
				}
			}
			catch( BadMethodCallException ) { }
		}
		
		/* Return */
		return ( $hiddenByFilter or $itemHiddenByFilter );
	}

	/**
	 * Check comment against profanity filters AND act on it
	 *
	 * @param bool $first Is this the first comment?
	 * @param bool $edit Are we editing or merging (true) or is this a new comment (false)?
	 * @param string|null $content The content to check - useful for if the content needs to be checked first, before it gets saved to the database.
	 * @param string|NULL|bool $title The title of the content to check, or NULL to check the current title, or FALSE to not check at all.
	 * @param string|null $autoSaveLocation The autosave location key of any editors to check, or NULL to use the default.
	 * @param array|null $autoSaveKeys The autosave keys (for new content) or attach lookup ids (for an edit) of any editors to check, or NULL to use the default.
	 * @param array $imageUploads Images that have been uploaded that may require moderation
	 * @return    bool                                    Whether to send unapproved notifications (i.e. true if the content was hidden)
	 */
	public function checkProfanityFilters( bool $first=FALSE, bool $edit=TRUE, ?string $content=NULL, string|null|bool $title=NULL, ?string $autoSaveLocation=NULL, array|null $autoSaveKeys=NULL, array $imageUploads=[] ): bool
	{
		/* Check it */
		$hiddenByFilter = FALSE;
		$itemHiddenByFilter = FALSE;
		$sendNotifications = FALSE;
		$filtersMatched = array();
		$item = ( $this instanceof Item ) ? $this : $this->item();
		$this->shouldTriggerProfanityFilters( $first, $edit, $content, $title, $autoSaveLocation, $autoSaveKeys, $imageUploads, $hiddenByFilter, $itemHiddenByFilter, $filtersMatched );

		/* If we need to hide the item, then do that */
		if ( $itemHiddenByFilter )
		{
			$sendNotifications = $edit;
			
			/* 'approved' is easy, clear and concise */
			if ( isset( $item::$databaseColumnMap['approved'] ) )
			{
				$column = $item::$databaseColumnMap['approved'];
				$item->$column = 0;
				$item->save();
			}
			/* 'hidden' is backwards */
			elseif ( isset( $item::$databaseColumnMap['hidden'] ) )
			{
				$column = $item::$databaseColumnMap['hidden'];
				$item->$column = 1;
				$item->save();
			}
		}
		
		/* If we need to hide this, then do that */
		if ( $hiddenByFilter or $itemHiddenByFilter )
		{
			$sendNotifications = TRUE;
			
			/* 'approved' is easy, clear and concise */
			if ( isset( static::$databaseColumnMap['approved'] ) )
			{
				$column = static::$databaseColumnMap['approved'];
				$this->$column = 0;
				$this->save();
			}
			/* 'hidden' is backwards */
			else if ( isset( static::$databaseColumnMap['hidden'] ) )
			{
				$column = static::$databaseColumnMap['hidden'];
				$this->$column = ( $itemHiddenByFilter and $this instanceof Comment ) ? 2 : 1; # We use the special 2 flag to note it is only hidden because the parent is
				$this->save();
			}
			
			$idColumn = static::$databaseColumnId;
			$itemColumnId = $item::$databaseColumnId;
			
			try
			{
				if ( $this instanceof Comment AND $item::$firstCommentRequired AND $this->isFirst() )
				{
					Approval::loadFromContent( get_class( $item ), $item->$itemColumnId );
				}
				else
				{
					Approval::loadFromContent( get_called_class(), $this->$idColumn );
				}
			}
			catch( OutOfRangeException )
			{
				if ( isset( $filtersMatched['type'] ) AND isset( $filtersMatched['match'] ) )
				{
					if ( $this instanceof Comment AND $item::$firstCommentRequired AND $this->isFirst() )
					{
						$itemToApprove = $item;
					}
					else
					{
						$itemToApprove = $this;
					}

					$reason	= $filtersMatched['type'];
					$data = [];

					switch( $filtersMatched['type'] )
					{
						case 'profanity':
							$data = [ 'word' => $filtersMatched['match'] ];
							break;
						
						case 'url':
							$data = [ 'url' => $filtersMatched['match'] ];
							break;
						
						case 'email':
							$data = [ 'email' => $filtersMatched['match'] ];
							break;
					}
					Approval::create( $itemToApprove, $reason, $data );
				}
			}
		}
		
		/* If we did either, then recount number of comments */
		if ( $itemHiddenByFilter or $hiddenByFilter )
		{
			$item->resyncCommentCounts();
			$item->resyncLastComment();
			$item->save();

			if ( $first AND $container = $item->containerWrapper() )
			{
				$container->resetCommentCounts();
				$container->save();
			}
		}

		/* Return */
		return $sendNotifications;
	}
	
	/**
	 * Can hide?
	 *
	 * @param	Member|NULL	$member	The member to check for (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canHide( ?Member $member=NULL ): bool
	{
		/* Extensions go first */
		if( $permCheck = Permissions::can( 'hide', $this, $member ) )
		{
			return ( $permCheck === Permissions::PERM_ALLOW );
		}

		if( !$this->actionEnabled( 'hide', $member ) )
		{
			return false;
		}

		if ( $this->hidden() < 0 )
		{
			return FALSE;
		}

		$member = $member ?: Member::loggedIn();
		if ( $this instanceof Comment )
		{
			return ( !$this->isFirst() and ( static::modPermission( 'hide', $member, $this->item()->containerWrapper() ) or ( $member->member_id and $member->member_id == $this->author()->member_id and ( $member->group['g_hide_own_posts'] == '1' or in_array( get_class( $this->item() ), explode( ',', $member->group['g_hide_own_posts'] ) ) ) ) ) );
		}

		return ( $member->member_id and ( static::modPermission( 'hide', $member, $this->containerWrapper() ) or ( $member->member_id == $this->author()->member_id and ( $member->group['g_hide_own_posts'] == '1' or in_array( get_class( $this ), explode( ',', $member->group['g_hide_own_posts'] ) ) ) ) ) );
	}
	
	/**
	 * Can unhide?
	 *
	 * @param	Member|NULL	$member	The member to check for (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canUnhide( ?Member $member=NULL ): bool
	{
		/* Extensions go first */
		if( $permCheck = Permissions::can( 'unhide', $this, $member ) )
		{
			return ( $permCheck === Permissions::PERM_ALLOW );
		}

		if( !$this->actionEnabled( 'unhide', $member ) )
		{
			return false;
		}

		if ( $this->hidden() === 0 )
		{
			return FALSE;
		}

		/* Check delayed deletes */
		if ( $this->hidden() == -2 )
		{
			return FALSE;
		}

		$member = $member ?: Member::loggedIn();
		if ( $this instanceof Comment )
		{
			$hiddenByItem = FALSE;
			if ( isset( static::$databaseColumnMap['hidden'] ) )
			{
				$column = static::$databaseColumnMap['hidden'];
				$hiddenByItem = ( $this->$column === 2 );
			}
	
			return ( !$this->isFirst() and ! $hiddenByItem and static::modPermission( 'unhide', $member, $this->item()->containerWrapper() ) );
		}

		return ( $member->member_id and ( static::modPermission( 'unhide', $member, $this->containerWrapper() ) ) );
	}
	
	/**
	 * Can view hidden items?
	 *
	 * @param	Member|NULL	    $member	        The member to check for (NULL for currently logged in member)
	 * @param   Model|null    $container      Container
	 * @return	bool
	 * @note	If called without passing $container, this method falls back to global "can view hidden content" moderator permission which isn't always what you want - pass $container if in doubt or use canViewHiddenItemsContainers()
	 */
	public static function canViewHiddenItems( ?Member $member=NULL, ?Model $container = NULL ): bool
	{
		$member = $member ?: Member::loggedIn();
		return $container ? static::modPermission( 'view_hidden', $member, $container ) : $member->modPermission( "can_view_hidden_content" );
	}
	
	/**
	 * Container IDs that the member can view hidden items in
	 *
	 * @param	Member|NULL	    $member	        The member to check for (NULL for currently logged in member)
	 * @return	bool|array				TRUE means all, FALSE means none
	 */
	public static function canViewHiddenItemsContainers( ?Member $member=NULL ): bool|array
	{
		$member = $member ?: Member::loggedIn();
		if ( $member->modPermission( "can_view_hidden_content" ) )
		{
			return TRUE;
		}
		elseif ( $member->modPermission( "can_view_hidden_" . static::$title ) )
		{
			if ( !isset( static::$containerNodeClass ) )
			{
				return TRUE;
			}

			$containerClass = static::$containerNodeClass;
			if ( isset( $containerClass::$modPerm ) )
			{
				$containers = $member->modPermission( $containerClass::$modPerm );
				if ( $containers === -1 )
				{
					return TRUE;
				}
				return $containers;
			}
			else
			{
				return TRUE;
			}
		}
		
		return FALSE;
	}

	/**
	 * Can view hidden comments on this item?
	 *
	 * @param	Member|NULL	$member	The member to check for (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canViewHiddenComments( ?Member $member=NULL ): bool
	{
		/* Extensions go first */
		if( $permCheck = Permissions::can( 'view_hidden', $this, $member ) )
		{
			return ( $permCheck === Permissions::PERM_ALLOW );
		}

		$commentClass = static::$commentClass;

		return $commentClass::modPermission( 'view_hidden', $member, $this->containerWrapper() );
	}
	
	/**
	 * Can view hidden reviews on this item?
	 *
	 * @param	Member|NULL	$member	The member to check for (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canViewHiddenReviews( ?Member $member=NULL ): bool
	{
		/* Extensions go first */
		if( $permCheck = Permissions::can( 'view_hidden', $this, $member ) )
		{
			return ( $permCheck === Permissions::PERM_ALLOW );
		}

		$reviewClass = static::$reviewClass;

		return $reviewClass::modPermission( 'view_hidden', $member, $this->containerWrapper() );
	}
	
	/**
	 * Item is moderator hidden by a moderator
	 *
	 * @return	boolean
	 * @throws	RuntimeException
	 */
	public function approvedButHidden(): bool
	{
		if ( isset( static::$databaseColumnMap['hidden'] ) )
		{
			$column = static::$databaseColumnMap['hidden'];
			return $this->$column == 2;
		}
		elseif ( isset( static::$databaseColumnMap['approved'] ) )
		{
			$column = static::$databaseColumnMap['approved'];
			return $this->$column == -1;
		}
		else
		{
			throw new RuntimeException;
		}
	}
	
	/**
	 * Should new items be moderated?
	 *
	 * @param	Member		$member		The member posting
	 * @param	Model|NULL	$container	The container
	 * @param	bool			$considerPostBeforeRegistering	If TRUE, and $member is a guest, will check if a newly registered member would be moderated
	 * @return	bool
	 */
	public static function moderateNewItems( Member $member, ?Model $container = NULL, bool $considerPostBeforeRegistering = FALSE ): bool
	{
		if ( $container and $container->checkAction( 'moderate_items' ) and !$member->group['g_avoid_q'] )
		{
			return !static::modPermission( 'approve', $member, $container );
		}

		return $member->moderateNewContent( $considerPostBeforeRegistering );
	}
	
	/**
	 * Should new comments be moderated?
	 *
	 * @param	Member	$member							The member posting
	 * @param	bool		$considerPostBeforeRegistering	If TRUE, and $member is a guest, will check if a newly registered member would be moderated
	 * @return	bool
	 */
	public function moderateNewComments( Member $member, bool $considerPostBeforeRegistering = FALSE ): bool
	{
		if( ( $this instanceof Item and $this->containerWrapper() ) )
		{
			if ( $this->container()->checkAction( 'moderate_comments' ) and ! $member->group['g_avoid_q'] )
			{
				return true;
			}
		}

		$return = $member->moderateNewContent( $considerPostBeforeRegistering );
		
		if ( $return === false AND IPS::classUsesTrait( $this, 'IPS\Content\MetaData' ) AND static::supportedMetaDataTypes() !== NULL AND in_array( 'core_ItemModeration', static::supportedMetaDataTypes() ) )
		{
			$return = $this->itemModerationEnabled( $member );
		}

		return $return;
	}
	
	/**
	 * Should new reviews be moderated?
	 *
	 * @param	Member	$member							The member posting
	 * @param	bool		$considerPostBeforeRegistering	If TRUE, and $member is a guest, will check if a newly registered member would be moderated
	 * @return	bool
	 */
	public function moderateNewReviews( Member $member, bool $considerPostBeforeRegistering = FALSE ): bool
	{
		if( ( $this instanceof Item and $this->containerWrapper() ) )
		{
			if( $this->container()->checkAction( 'moderate_reviews' ) and ! $member->group['g_avoid_q'] )
			{
				return true;
			}
		}

		$return = $member->moderateNewContent( $considerPostBeforeRegistering );
		
		if ( $return === FALSE AND IPS::classUsesTrait( $this, 'IPS\Content\MetaData' ) AND static::supportedMetaDataTypes() !== NULL AND in_array( 'core_ItemModeration', static::supportedMetaDataTypes() ) )
		{
			$return = $this->itemModerationEnabled( $member );
		}
		
		return $return;
	}
	
	/**
	 * Syncing to run when hiding
	 *
	 * @param	Member|NULL|FALSE	$member	The member doing the action (NULL for currently logged in member, FALSE for no member)
	 * @return	void
	 */
	public function onHide( Member|null|bool $member ): void
	{
		if ( $this instanceof Item )
		{
			if ( method_exists( $this, 'container' ) )
			{
				try
				{
					$container = $this->container();

					if( $container->_items !== null )
					{
						$container->_items = ( $container->_items >=0 ) ? ( $container->_items - 1 ) : 0;
					}
	
					if ( isset( static::$commentClass ) )
					{
						$container->setLastComment();
					}
					if ( isset( static::$reviewClass ) )
					{
						if( !$this->hidden() )
						{
							$container->_reviews = $container->_reviews - $this->mapped('num_reviews');
						}
	
						$container->setLastReview();
					}
	
					$container->resetCommentCounts();
					$container->save();
				}
				catch ( BadMethodCallException ) { }
			}

			/* If this is a tagged class, remove it if it was pinned */
			if( IPS::classUsesTrait( $this, Taggable::class ) )
			{
				$idColumn = static::$databaseColumnId;
				Db::i()->delete( 'core_tags_pinned', [ 'pinned_item_class=? and pinned_item_id=?', get_called_class(), $this->$idColumn ] );
			}
		}
		else if ( $this instanceof Comment )
		{
			if ( $this instanceof Review )
			{
				$item = $this->item();
				if ( $item->container()->_reviews !== NULL )
				{
					$item->container()->_reviews = ( $item->container()->_reviews > 0 ) ? ( $item->container()->_reviews - 1 ) : 0;
					$item->container()->setLastReview();
					$item->container()->save();
				}
				if ( isset( $item::$databaseColumnMap['hidden_reviews'] ) )
				{
					$column = $item::$databaseColumnMap['hidden_reviews'];
					$item->$column = $item->mapped('hidden_reviews') + 1;
				}
				
				if ( isset( $item::$databaseColumnMap['num_reviews'] ) )
				{
					$column = $item::$databaseColumnMap['num_reviews'];
					$item->$column = $item->mapped('num_reviews') - 1;
				}

				if( isset( $item::$databaseColumnMap['rating'] ) )
				{
					$ratingField = $item::$databaseColumnMap['rating'];
					$item->$ratingField = $item->averageReviewRating() ?: 0;
				}
		
				$item->resyncLastReview();
				$item->save();
			}
			else
			{
				$item = $this->item();

				$this->rebuildItemStats( $item );
		
				/* Remove any notifications */
				$idColumn = static::$databaseColumnId;
				Db::i()->delete( 'core_notifications', array( 'item_sub_class=? AND item_sub_id=?', get_called_class(), (int) $this->$idColumn ) );

				if ( $item->container()->_comments !== NULL )
				{
					$item->container()->setLastComment();
					$item->container()->_comments = ( $item->container()->_comments > 0 ) ? ( $item->container()->_comments - 1 ) : 0;
					$item->container()->resetCommentCounts();
					$item->container()->save();
				}
			}
		}
		
		Event::fire( 'onStatusChange', $this, array( 'hide' ) );

		/* If we have an ItemTopic we need ot keep it in synch */
		if( IPS::classUsesTrait( $this, 'IPS\Content\ItemTopic' ) )
		{
			$this->itemHidden( $member );
		}
	}
	
	/**
	 * Syncing to run when unhiding
	 *
	 * @param	bool					$approving	If true, is being approved for the first time
	 * @param	Member|NULL|FALSE	$member	The member doing the action (NULL for currently logged in member, FALSE for no member)
	 * @return	void
	 */
	public function onUnhide( bool $approving, Member|null|bool $member ): void
	{
		if ( $this instanceof Item )
		{
			/* If approving, we may need to increase the post count */
			if ( $approving AND isset( static::$commentClass ) )
			{
				$commentClass = static::$commentClass;
				if ( ( IPS::classUsesTrait( $this, 'IPS\Content\Anonymous' ) AND !$this->isAnonymous() ) and ( static::$firstCommentRequired and $commentClass::incrementPostCount( $this->containerWrapper() ) ) or static::incrementPostCount( $this->containerWrapper() ) )
				{
					$this->author()->member_posts++;
					$this->author()->save();
				}
			}
			
			/* Update container */
			if ( method_exists( $this, 'container' ) )
			{
				try
				{
					$container = $this->container();

					if( $container->_items !== null )
					{
						$container->_items = ( $container->_items + 1 );
					}

					if( $approving and $container->_unapprovedItems !== null )
					{
						$container->_unapprovedItems = ( $container->_unapprovedItems >= 0 ) ? ( $container->_unapprovedItems - 1 ) : 0;
					}

					$resetCommentCounts = TRUE;
					if ( IPS::classUsesTrait( $this, 'IPS\Content\FuturePublishing' ) AND $this->isFutureDate() )
					{
						$resetCommentCounts = FALSE;
					}

					if ( $resetCommentCounts )
					{
						$container->resetCommentCounts();

						if ( isset( static::$commentClass ) )
						{
							$container->setLastComment( null, $this );
						}
						if ( isset( static::$reviewClass ) )
						{
							$container->_reviews = $container->_reviews + $this->mapped('num_reviews');
							$container->setLastReview();
						}

						$container->save();
					}
				}
				catch ( BadMethodCallException ) { }
			}
		}
		else if ( $this instanceof Comment )
		{
			if ( $this instanceof Review )
			{
				$item = $this->item();

				if ( $approving )
				{
					if ( isset( $item::$databaseColumnMap['unapproved_reviews'] ) )
					{
						$column = $item::$databaseColumnMap['unapproved_reviews'];
						$item->$column = $item->mapped('unapproved_reviews') - 1;
					}
					if ( $item->container()->_unapprovedReviews !== NULL )
					{
						$item->container()->_unapprovedReviews = ( $item->container()->_unapprovedReviews > 0 ) ? ( $item->container()->_unapprovedReviews - 1 ) : 0;
						$item->container()->setLastReview();
						$item->container()->save();
					}
				}
				else if ( isset( $item::$databaseColumnMap['hidden_reviews'] ) )
				{
					$column = $item::$databaseColumnMap['hidden_reviews'];
					if ( $item->mapped('hidden_reviews') > 0 )
					{
						$item->$column = $item->mapped('hidden_reviews') - 1;
					}
				}
		
				if ( isset( $item::$databaseColumnMap['num_reviews'] ) )
				{
					$column = $item::$databaseColumnMap['num_reviews'];
					$item->$column = $item->mapped('num_reviews') + 1;
				}
				if ( $item->container()->_reviews !== NULL )
				{
					$item->container()->_reviews = $item->container()->_reviews + 1;
					$item->container()->setLastReview();
					$item->container()->save();
				}

				if( isset( $item::$databaseColumnMap['rating'] ) )
				{
					$ratingField = $item::$databaseColumnMap['rating'];
					$item->$ratingField = $item->averageReviewRating() ?: 0;
				}
				
				$item->resyncLastReview();
				$item->save();
			}
			else
			{
				$item = $this->item();
		
				if ( $approving )
				{
					/* We should only do this if it is an actual account, and not a guest. */
					if ( $this->author()->member_id )
					{
						try
						{
							if ( static::incrementPostCount( $item->container() ) )
							{
								$this->author()->member_posts++;
								$this->author()->save();
							}
						}
						catch( BadMethodCallException ) { }
					}
				}

				$this->rebuildItemStats( $item );

				if ( $item->container()->_comments !== NULL )
				{
					$item->container()->setLastComment( $this, $item );
					$item->container()->_comments = ( $item->container()->_comments + 1 );
					$item->container()->resetCommentCounts();
					$item->container()->save();
				}

				if( $item->container()->_unapprovedComments !== NULL and $approving )
				{
					$item->container()->_unapprovedComments = ( $item->container()->_unapprovedComments > 0 ) ? ( $item->container()->_unapprovedComments - 1 ) : 0;
					$item->container()->save();
				}
			}
		}
		
		Event::fire( 'onStatusChange', $this, array( 'unhide' ) );

		/* If we use ItemTopic, keep it in synch */
		if( IPS::classUsesTrait( $this, 'IPS\Content\ItemTopic' ) )
		{
			$this->itemUnhidden( $approving, $member );
		}
	}

	/**
	 * Resynch and recount
	 *
	 * @param Item $item
	 * @return void
	 */
	protected function rebuildItemStats( Item $item ) : void
	{
		$itemIdColumn = $item::$databaseColumnId;
		$item->resyncCommentCounts();
		$item->resyncLastComment();
		$item->save();

		/* We have to do this *after* updating the last comment data for the item, because that uses the cached data from the item (i.e. topic) */
		try
		{
			/* Update mappings */
			if ( IPS::classUsesTrait( $item->container(), 'IPS\Node\Statistics' ) )
			{
				$item->container()->rebuildPostedIn( array( $item->$itemIdColumn ), array( $this->author() ) );
			}
		} catch ( BadMethodCallException ) {}

		if ( IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
		{
			$item->clearCachedStatistics();
		}

		/* Send the content items URL to IndexNow if the guest can view it */
		if( $item->canView( new Member ) )
		{
			IndexNow::addUrlToQueue( $item->url() );
		}
	}
}