<?php
/**
 * @brief		Content Controller
 * @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		5 Jul 2013
 */

namespace IPS\Content;

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

use BadMethodCallException;
use DateInterval;
use DomainException;
use ErrorException;
use Exception;
use InvalidArgumentException;
use IPS\Api\Webhook;
use IPS\Application;
use IPS\cloud\Application as CloudApplication;
use IPS\cloud\Workflow;
use IPS\Content;
use IPS\Content\Search\Index;
use IPS\Content\Search\Query;
use IPS\Content\Search\SearchContent;
use IPS\core\Alerts\Alert;
use IPS\core\DataLayer;
use IPS\core\Facebook\Pixel;
use IPS\core\Feature;
use IPS\core\FrontNavigation;
use IPS\core\Reports\Report;
use IPS\core\Reports\Types;
use IPS\DateTime;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Events\Event;
use IPS\File;
use IPS\Helpers\CoverPhoto;
use IPS\Helpers\CoverPhoto\Controller as CoverPhotoController;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Captcha;
use IPS\Helpers\Form\Checkbox;
use IPS\Helpers\Form\Custom;
use IPS\Helpers\Form\Editor;
use IPS\Helpers\Form\Email;
use IPS\Helpers\Form\Node;
use IPS\Helpers\Form\Radio;
use IPS\Helpers\Form\Rating;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\TextArea;
use IPS\Helpers\Form\Upload;
use IPS\Helpers\Form\Url as FormUrl;
use IPS\Helpers\Form\YesNo;
use IPS\Helpers\Table\Db as TableDb;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Lang;
use IPS\Log;
use IPS\Member;
use IPS\Output;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
use IPS\Theme;
use LengthException;
use LogicException;
use OutOfBoundsException;
use OutOfRangeException;
use RuntimeException;
use UnderflowException;
use function array_keys;
use function array_merge;
use function count;
use function defined;
use function get_class;
use function in_array;
use function intval;
use function is_array;
use function is_subclass_of;
use function time;

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

/**
 * Content Controller
 * @method multimodComment( string $string, Controller $controller)
 */
class Controller extends CoverPhotoController
{
	/**
	 * @brief	Should views and item markers be updated by AJAX requests?
	 */
	protected bool $updateViewsAndMarkersOnAjax = FALSE;
	
	/**
	 * Execute
	 *
	 * @return	void
	 */
	public function execute() : void
	{
		/* We do this to prevent SQL errors with page offsets */
		if ( isset( Request::i()->page ) )
		{
			Request::i()->page	= intval( Request::i()->page );
			if ( !Request::i()->page OR Request::i()->page < 1 )
			{
				Request::i()->page	= 1;
			}
		}

		/* Ensure JS loaded for forms/content functions such as moderation */
		Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'front_core.js', 'core' ) );
		Output::i()->bodyAttributes['contentClass'] = static::$contentModel;

		parent::execute();

		/* Do this to prevent non-existent and non-accessible items from returning a status of 200 */
		if ( empty( Output::i()->output ) AND !Request::i()->_bypassItemIdCheck AND isset( Request::i()->id ) )
		{
			$contentModel = static::$contentModel;
			try
			{
				$item = $contentModel::load( Request::i()->id );
				if ( !$item->can( 'read' ) )
				{
					Output::i()->error( 'node_error', '2S136/1X', 403, '' );
				}
			}
			catch ( OutOfRangeException $e )
			{
				Output::i()->error( 'node_error', '2S136/1Y', 404, '' );
			}
		}
	}

	/**
	 * View Item
	 *
	 * @return    mixed
	 */
	protected function manage() : mixed
	{
		try
		{
			$class = static::$contentModel;
			/**@var Content $item */
			$item = $class::loadAndCheckPerms( Request::i()->id );

			/* Have we moved? If a user has loaded a forum, and a moderator merges two topics leaving a link, then we need to account for this if the
				user happens to hover over the topic that was removed before refreshing the page. */
			if ( isset( $class::$databaseColumnMap['state'] ) AND isset( $class::$databaseColumnMap['moved_to'] ) )
			{
				$stateColumn	= $class::$databaseColumnMap['state'];
				$movedToColumn	= $class::$databaseColumnMap['moved_to'];
				$movedTo		= explode( '&', $item->$movedToColumn );
				
				if ( $item->$stateColumn == 'link' OR $item->$stateColumn == 'merged' )
				{
					try
					{
						$moved = $class::loadAndCheckPerms( $movedTo[0] );

						if ( Request::i()->isAjax() AND Request::i()->preview )
						{
							return null;
						}

						Output::i()->redirect( $moved->url() );
					}
					catch( OutOfRangeException $e ) { }
				}
			}

			/* Publish if required */
			if ( IPS::classUsesTrait( $item, 'IPS\Content\FuturePublishing' ) and isset( $class::$databaseColumnMap['is_future_entry'] ) AND isset( $class::$databaseColumnMap['future_date'] ) )
			{
				$futureColumn = $class::$databaseColumnMap['future_date'];
				$futureEntry = $class::$databaseColumnMap['is_future_entry'];
				if ( $item->$futureEntry == 1 and $item->$futureColumn <= time() )
				{
					$item->publish();
				}
			}

			/* If this is an AJAX request (like the topic hovercard), we don't want to do any of the below like update views and mark read */
			if ( Request::i()->isAjax() and !$this->updateViewsAndMarkersOnAjax )
			{
				/* But we do want to mark read if we are paging through the content */
				if( IPS::classUsesTrait( $item, 'IPS\Content\ReadMarkers' ) AND isset( Request::i()->page ) AND Request::i()->page AND $item->isLastPage() )
				{
					$item->markRead();
				}

				/* We also want to update the views if we have a page parameter */
				if( IPS::classUsesTrait( $item, 'IPS\Content\ViewUpdates' ) and isset( $class::$databaseColumnMap['views'] ) AND isset( Request::i()->page ) AND Request::i()->page )
				{
					/* @var $item ViewUpdates */
					$item->updateViews();
				}

				return $item;
			}

			/* Do we need to convert any legacy URL parameters? */
			if( $redirectToUrl = $item->checkForLegacyParameters() )
			{
				Output::i()->redirect( $redirectToUrl );
			}

			/* Get ready to store data layer info */
			$dataLayerProperties = array();
			if ( DataLayer::enabled() )
			{
				$dataLayerProperties = $item->getDataLayerProperties();
			}

			/* Check we're on a valid page */
			$paginationType = 'comment';
			if ( isset( Request::i()->tab ) and  Request::i()->tab === 'reviews' )
			{
				$paginationType = 'review';
			}
			
			$methodName = "{$paginationType}PageCount";
			$pageCount = $item->$methodName();

			if ( isset( Request::i()->page ) )
			{
				$paginationType	= NULL;
				$container		= ( isset( $item::$databaseColumnMap['container'] ) ) ? $item->container() : NULL;

				if( $item::supportsComments( NULL, $container ) )
				{
					$paginationType	= 'comment';
				}

				if( ( isset( Request::i()->tab ) and Request::i()->tab === 'reviews' AND $item::supportsReviews( NULL, $container ) ) OR
					( $item::supportsReviews( NULL, $container ) AND $paginationType === NULL ) )
				{
					$paginationType = 'review';
				}

				if( $paginationType !== NULL )
				{
					$methodName = "{$paginationType}PageCount";
					$pageCount = $item->$methodName();
					if ( $pageCount and Request::i()->page > $pageCount )
					{
						$lastPageMethod = 'last' . IPS::mb_ucfirst( $paginationType ) . 'PageUrl';
						Output::i()->redirect( $item->$lastPageMethod(), NULL, 303 );
					}

					$dataLayerProperties['page_number'] = intval( Request::i()->page );
				}

				/* Add rel tags */
				if( Request::i()->page != 1 )
				{
					Output::i()->linkTags['first'] = (string) $item->url();

					if( Request::i()->page - 1 > 1 )
					{
						Output::i()->linkTags['prev'] = (string) $item->url()->setPage( 'page', Request::i()->page - 1 );
					}
					else
					{
						Output::i()->linkTags['prev'] = (string) $item->url();
					}
				}
				/* If we literally requested ?page=1 add canonical tag to get rid of the page query string param */
				elseif( isset( $item->url()->data[ Url::COMPONENT_QUERY ]['page' ] ) )
				{
					Output::i()->linkTags['canonical'] = (string) $item->url();
				}
			}

			/* Add rel tags */
			if ( $pageCount > 1 AND ( !Request::i()->page OR $pageCount > Request::i()->page ) )
			{
				Output::i()->linkTags['next'] = (string) $item->url()->setPage( 'page', ( Request::i()->page ?: 1 ) + 1 );
			}
			if ( $pageCount > 1 AND ( !Request::i()->page OR $pageCount != Request::i()->page ) )
			{
				Output::i()->linkTags['last'] = (string) $item->url()->setPage( 'page', $pageCount );
			}

			/* Update Views */
			$idColumn = $class::$databaseColumnId;
			if ( IPS::classUsesTrait( $class, 'IPS\Content\ViewUpdates' ) AND isset( $class::$databaseColumnMap['views'] ) )
			{
				/* @var $item ViewUpdates */
				$item->updateViews();
			}
			else
			{
				/* Fire the event for items that don't implement the ViewUpdates trait */
				Event::fire( 'onItemView', $item );
			}
						
			/* Mark read */
			if( IPS::classUsesTrait( $item, 'IPS\Content\ReadMarkers' ) )
			{	
				/* Note time last read before we mark it read so that the line is in the right place */
				$item->timeLastRead();
				
				if ( $item->isLastPage() )
				{
					$item->markRead();
				}
			}
			
			/* Set navigation and title */
			$this->_setBreadcrumbAndTitle( $item, FALSE );
			
			/* Set meta tags */
			Output::i()->linkTags['canonical'] = (string) ( Request::i()->page > 1 ) ? $item->url()->setPage( 'page', Request::i()->page ) : $item->url() ;
			Output::i()->metaTags['og:title'] = $item->mapped( 'title' );
			Output::i()->metaTags['og:type'] = 'website';
			Output::i()->metaTags['og:url'] = (string) $item->url();
			
			/* Do not set description tags for page 2+ as you end up with duplicate tags */
			if ( Request::i()->page < 2 )
			{
				Output::i()->metaTags['description'] = $item->metaDescription();
				/* If we had $_SESSION['_findComment'] and a specific comment's text was pulled, that var would have been wiped out on the first call */
				Output::i()->metaTags['og:description'] = Output::i()->metaTags['description'];
			}

			/* Facebook Pixel */
			$itemId = $class::$databaseColumnId;
			Pixel::i()->PageView = array(
				'item_id' => $item->$itemId,
				'item_name' => $item->mapped( 'title' ),
				'item_type' => $class::$contentType ?? $class::$title,
				'category_name' => isset( $item::$databaseColumnMap['container'] ) ? $item->container()->_title : NULL
			);

			/* Data Layer */
			if ( DataLayer::enabled() )
			{
				if ( !Request::i()->isAjax() and $item::dataLayerEventActive( 'content_view' ) )
				{
					DataLayer::i()->addEvent( 'content_view', $dataLayerProperties );
				}
				unset( $dataLayerProperties['ips_key'] );
				foreach ( $dataLayerProperties as $key => $value )
				{
					DataLayer::i()->addContextProperty( $key, $value );
				}
			}

			if( $item->mapped( 'updated' ) OR $item->mapped( 'last_comment' ) OR $item->mapped( 'last_review' ) )
			{
				Output::i()->metaTags['og:updated_time'] = DateTime::ts( $item->mapped( 'updated' ) ? $item->mapped( 'updated' ) : ( $item->mapped( 'last_comment' ) ? $item->mapped( 'last_comment' ) : $item->mapped( 'last_review' ) ) )->rfc3339();
			}

			if( IPS::classUsesTrait( $item, 'IPS\Content\Taggable' ) )
			{
				$tags = array();

				if( $item->prefix() !== NULL )
				{
					$tags[]	= $item->prefix();
				}

				if( $item->tags() !== NULL )
				{
					$tags = array_merge( $tags, $item->tags() );
				}

				if( count( $tags ) )
				{
					Output::i()->metaTags['keywords'] = implode( ', ', $tags );
				}
			}
			
			/* Add contextual search options */
			if( SearchContent::isSearchable( $item ) )
			{
				Output::i()->contextualSearchOptions[ Member::loggedIn()->language()->addToStack( 'search_contextual_item_' . $item::$title ) ] = array( 'type' => mb_strtolower( str_replace( '\\', '_', mb_substr( $class, 4 ) ) ), 'item' => $item->$idColumn );

				try
				{
					$container = $item->container();
					Output::i()->contextualSearchOptions[ Member::loggedIn()->language()->addToStack( 'search_contextual_item_' . $container::$nodeTitle ) ] = array( 'type' => mb_strtolower( str_replace( '\\', '_', mb_substr( $class, 4 ) ) ), 'nodes' => $container->_id );
				}
				catch ( BadMethodCallException $e ) { }
			}

			/* Contextual Sidebar */
			if( !isset( Output::i()->sidebar['contextual'] ) )
			{
				Output::i()->sidebar['contextual'] = '';
			}

			Output::i()->sidebar['contextual'] .= $item->ui( 'sidebar' );

			if( Application::appIsEnabled( 'cloud' ) )
			{
				Output::setCacheTime( CloudApplication::getCacheDateForPage( $item->mapped('updated') ?? 0, ( ( $item::$application === 'forums' and $item->isArchived() ) ? 'archived' : 'content' ) ) );
			}

			/* Return */
			return $item;
		}
		catch ( LogicException $e )
		{
			try
			{
				if ( ! is_int( Request::i()->id ) )
				{
					throw new BadMethodCallException( $e );
				}
				Output::i()->redirect( $class::getRedirectFrom( Request::i()->id )->url() );
			}
			catch( BadMethodCallException|UnderflowException $e )
			{
				/* There is no valid redirect, so we will just show an error */
				Output::i()->error( 'page_doesnt_exist', '2S136/1Z', 404, '' );
			}

			return NULL;
		}
	}

	/**
	 * Get the item for this controller
	 *
	 * @param int|null	$id
	 *
	 * @return Item|null
	 */
	public static function loadItem( ?int $id=null ) : Item|null
	{
		if ( $id === null )
		{
			$id = Request::i()->id;
		}

		if ( is_numeric( $id ) )
		{
			$id = (int) $id;
		}

		if ( !is_int( $id ) )
		{
			return null;
		}

		try
		{
			$class = static::$contentModel;
			return $class::loadAndCheckPerms( $id );
		}
		catch ( UnderflowException|OutOfBoundsException|OutOfRangeException )
		{
			return null;
		}
	}
	
	/**
	 * AJAX - check for new replies
	 *
	 * @return    void
	 */
	protected function checkForNewReplies(): void
	{
		Session::i()->csrfCheck();

		/* If auto-polling isn't enabled, kill the polling now */
		if ( !Settings::i()->auto_polling_enabled )
		{
			Output::i()->json( array( 'error' => 'auto_polling_disabled' ) );
		}

		/* If we're filtering the topic (live topic questions) then the max commentID in the source will not be the max commentID possible
		   as not all comments will be shown */
		if ( isset( Request::i()->ltqid ) )
		{
			Output::i()->json( array( 'error' => 'auto_polling_disabled' ) );
		}

		/* If we're a guest, disable polling */
		if ( !Member::loggedIn()->member_id )
		{
			Output::i()->json( array( 'error' => 'auto_polling_disabled' ) );
		}

		try
		{
			/* no need for polling for embeds */
			if ( !isset( static::$contentModel ) )
			{
				Output::i()->json( array( 'error' => 'auto_polling_disabled' ) );
			}

			$class = static::$contentModel;

			/* no need for polling if the content item doesn't have comments */
			if ( !isset( $class::$commentClass ) )
			{
				Output::i()->json( array( 'error' => 'auto_polling_disabled' ) );
			}

			$item = $class::loadAndCheckPerms( Request::i()->id );
			$commentClass = $class::$commentClass;

			/* Don't auto-poll archived content, it never gets updated */
			if( isset( $item::$archiveClass ) AND method_exists( $item, 'isArchived' ) AND $item->isArchived() )
			{
				Output::i()->json( array( 'error' => 'auto_polling_disabled' ) );
			}

			/* @var $databaseColumnMap array */
			$commentIdColumn = $commentClass::$databaseColumnId;
			$commentDateColumn = $commentClass::$databaseColumnMap['date'];
			$perPage = $class::getCommentsPerPage();
			
			/* The form field has an underscore, but this value is sent in a query string value without an underscore via AJAX */
			if( ! Request::i()->lastSeenID or ! Member::loggedIn()->member_id )
			{
				Output::i()->json( array( 'count' => 0 ) );
			}

			$lastComment = $commentClass::load( Request::i()->lastSeenID );
			$authorColumn = $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['author'];

            $where = array();

            /* Ignored Users */
            if( ! Member::loggedIn()->members_bitoptions['has_no_ignored_users'] )
            {
                $ignored = iterator_to_array( Db::i()->select( 'ignore_ignore_id', 'core_ignored_users', array( 'ignore_owner_id=? and ignore_messages=?', Member::loggedIn()->member_id, 1 ) ) );
                if( count( $ignored ) )
                {
                    $where[] = array( Db::i()->in( $authorColumn, $ignored, TRUE ) );
                }
            }

            /* We will fetch up to 200 comments - anything over this is excessive */
			$newComments = $item->comments( 200, 0, 'date', 'asc', NULL, NULL, DateTime::ts( $lastComment->$commentDateColumn ), array_merge( $where, array( "{$authorColumn} != " . Member::loggedIn()->member_id ) ) );
			
			/* Get the next to last comment for the spillover link as PMs do not support read markers */
			$nextLastComment = NULL;
			if( count( $newComments ) )
			{
				$nextLastComment = reset( $newComments );
			}
			
			if ( Request::i()->type === 'count' )
			{
				$data = array(
					'totalNewCount'	=> count( $newComments ),
					'count'			=> count( $newComments ), 	/* This is here for legacy purposes only */
					'perPage'		=> $perPage,
					'totalCount'	=> $item->mapped( 'num_comments' ),
					'title'			=> $item->mapped( 'title' ) ,
					'spillOverUrl'	=> ( $nextLastComment ) ? $nextLastComment->url() : $item->url(),
				);

				if( $data['count'] === 1 ){
					$itemData = reset( $newComments );
					$author = $itemData->author();

					$data['name'] = htmlspecialchars( $author->name, ENT_DISALLOWED | ENT_QUOTES, 'UTF-8', FALSE );
					$data['photo'] = (string) $author->photo;
				}

				Output::i()->json( $data );
			}
			else
			{
				$output = array();
				$lastId = 0;
				$showing = intval( Request::i()->showing ?: 0 );
				foreach ( $newComments as $newComment )
				{
					$output[] = $newComment->html();
					$lastId = ( $newComment->$commentIdColumn > $lastId ) ? $newComment->$commentIdColumn : $lastId;
					if ( ++$showing >= $perPage )
					{
						break;
					}
				}

				/* Only mark as read if we'll be staying on this page, otherwise we'll be marking it read despite the user not having seen everything */
				if ( IPS::classUSesTrait( $class, 'IPS\Content\ReadMarkers' ) AND count( $newComments ) + intval( Request::i()->showing ) <= $perPage )
				{
					$item->markRead();
				}
			}
			
			Output::i()->json( array(
				'content' 		=> $output ?? null,
				'id' 			=> $lastId ?? null,
				'totalCount' 	=> $item->mapped( 'num_comments' ),
				'totalNewCount' => count( $newComments ),
				'perPage' 		=> $perPage,
				'spillOverUrl' 	=> $nextLastComment ? $nextLastComment->url() : $item->url()
			) );
		}
		catch ( Exception $e )
		{
			Output::i()->json( $e->getMessage(), 500 );
		}
	}
	
	/**
	 * Edit Item
	 *
	 * @return	void
	 */
	protected function edit() : void
	{
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );

			// We check if the form has been submitted to prevent the user loosing their content
			if ( isset( Request::i()->form_submitted ) )
			{
				if ( ! $item->couldEdit() )
				{
					throw new OutOfRangeException;
				}
			}
			else
			{
				if ( ! $item->canEdit() )
				{
					throw new OutOfRangeException;
				}
			}
			
			$container = NULL;
			try
			{
				$container = $item->container();
			}
			catch ( BadMethodCallException $e ) {}

			/* Build the form */
			$form = $item->buildEditForm();

			if ( $values = $form->values() )
			{
				/* @var $databaseColumnMap array */
				$titleField = $item::$databaseColumnMap['title'];
				$oldTitle = $item->$titleField;

				if ( $item->canEdit() )
				{
                    $item->processBeforeEdit( $values );
                    $item->processForm( $values );
					if ( isset( $item::$databaseColumnMap['updated'] ) )
					{
						$column = $item::$databaseColumnMap['updated'];
						$item->$column = time();
					}
	
					if ( isset( $item::$databaseColumnMap['date'] ) and isset( $values[ $item::$formLangPrefix . 'date' ] ) )
					{
						$column = $item::$databaseColumnMap['date'];
	
						if ( $values[ $item::$formLangPrefix . 'date' ] instanceof DateTime )
						{
							$item->$column = $values[ $item::$formLangPrefix . 'date' ]->getTimestamp();
						}
						else
						{
							$item->$column = time();
						}
					}

					$item->save();
					$item->processAfterEdit( $values );

					/* Moderator log */
					$toLog = array( $item::$title => TRUE, $item->url()->__toString() => FALSE, $item::$title => TRUE, $item->mapped( 'title' ) => FALSE );
					
					if ( $oldTitle != $item->$titleField )
					{
						array_push( $toLog, $oldTitle ); 
					}
					
					Session::i()->modLog( 'modlog__item_edit', $toLog, $item );

					Output::i()->redirect( $item->url() );
				}
				else
				{
					$form->error = Member::loggedIn()->language()->addToStack( 'edit_no_perm_err' );
				}
			}
			
			$this->_setBreadcrumbAndTitle( $item );

			if( Request::i()->isAjax() )
			{
				Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
			}
			else
			{
				Output::i()->output = Theme::i()->getTemplate( 'forms', 'core' )->editContentForm( Member::loggedIn()->language()->addToStack( 'edit_title', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( $item::$title ) ) ) ), $this->getEditForm( $form ), $container );
			}
		}
		catch ( Exception $e )
		{
			Log::debug( $e, 'content_debug' );
			Output::i()->error( 'edit_no_perm_err', '2S136/E', 404, '' );
		}
	}

	/**
	 * Return the form for editing. Abstracted so controllers can define a custom template if desired.
	 *
	 * @param	Form	$form	The form
	 * @return	string
	 */
	protected function getEditForm( Form $form ): string
	{
		return $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
	}

	/**
	 * Edit item's tags
	 *
	 * @return	void
	 */
	protected function editTags(): void
	{
		try
		{
			$class = static::$contentModel;
			/** @var Item $item */
			$item = $class::loadAndCheckPerms( Request::i()->id );

			/* Make sure tagging is supported in this class */
			if ( !IPS::classUsesTrait( $class, 'IPS\Content\Taggable' ) )
			{
				throw new DomainException;
			}

			/* Get the container as we'll need it for permission checking */
			$container = NULL;
			try
			{
				$container = $item->container();
			}
			catch ( BadMethodCallException $e ) {}

			/* Make sure we can edit and tag */
			if ( !$item->canEdit() OR ( !$item::canTag( NULL, $container )  AND !count( $item->tags() ) ) )
			{
				throw new OutOfRangeException;
			}

			/* If the tag form field is generated, create the form, otherwise throw an exception */
			if( $tagsField = $item::tagsFormField( $item, $container, TRUE, isset( Request::i()->removeTag ) ) )
			{
				$form = new Form( 'form', Member::loggedIn()->language()->checkKeyExists( $item::$formLangPrefix . '_save' ) ? $item::$formLangPrefix . '_save' : 'save' );
				$form->class = 'ipsForm--vertical ipsForm--fullWidth ipsForm--edit-tags';

				$form->add( $tagsField );
			}
			else
			{
				throw new DomainException;
			}

			/* Get our current tags */
			$existingTags = ( $item->prefix() ? array_merge( array( 'prefix' => $item->prefix() ), $item->tags() ) : $item->tags() );

			/* If we are simply removing a tag, do it */
			if( isset( Request::i()->removeTag ) )
			{
				/* Remove the tag from the array. Preserve index since it could be 'prefix' which we need to remember. */
				foreach( $existingTags as $index => $tag )
				{
					if( $tag == Request::i()->removeTag )
					{
						unset( $existingTags[ $index ] );
						break;
					}
				}

				/* Now set the tags */
				$name = $tagsField->name;
				Request::i()->$name = implode( "\n", $existingTags );

				if( isset( $existingTags['prefix'] ) )
				{
					$prefix = $tagsField->name . '_prefix';
					Request::i()->$prefix = $existingTags['prefix'];

					$prefix = $tagsField->name . '_freechoice_prefix';
					Request::i()->$prefix = 'on';
				}

				$tagsField->setValue( FALSE, TRUE );

				$submittedKey = $form->id . "_submitted";
				Request::i()->$submittedKey = 1;
			}

			/* Process the values */
			if ( $values = $form->values() )
			{
				$item->setTags( $values[ $item::$formLangPrefix . 'tags' ] ?: array() );

				/* Update the search index */
				if( SearchContent::isSearchable( $item ) )
				{
					if( $item::$firstCommentRequired )
					{
						Index::i()->index( $item->firstComment() );
					}
					else
					{
						Index::i()->index( $item );
					}
				}

				if ( Request::i()->isAjax() )
				{
					/* Build html we'll need to display */
					$toReturn = array( 'tags' => '', 'prefix' => '' );

					foreach ( $item->tags() as $tag )
					{
						$toReturn['tags'] .= Theme::i()->getTemplate( 'global', 'core' )->tag( $tag, $item->url() );
					}

					if( $item->prefix() )
					{
						$toReturn['prefix'] = Theme::i()->getTemplate( 'global', 'core' )->tag( $item->prefix(), $item->url(), 'ipsTags__item--prefix' );
					}

					$newTags = ( $item->prefix() ? array_merge( array( 'prefix' => $item->prefix() ), $item->tags() ) : $item->tags() );
					$toReturn['tagsChanged'] = (int) ( $newTags !== $existingTags );

					Output::i()->json( $toReturn );
				}
				else
				{
					Output::i()->redirect( $item->url() );
				}				
			}

			/* If we tried to delete a tag and this is an AJAX request, just return the error. If it's not AJAX, we
				can just let the regular form output which will show the error. */
			if( $tagsField->error AND Request::i()->isAjax() )
			{
				Output::i()->error( $tagsField->error, '1S136/13', 403, '' );
			}
			
			/* Show the output */
			$this->_setBreadcrumbAndTitle( $item );
			Output::i()->output = Theme::i()->getTemplate( 'forms', 'core' )->editTagsForm( $form );
		}
		catch ( DomainException $e )
		{
			Output::i()->error( 'edit_no_tags_defined', '2S131/3', 403, 'edit_no_tags_defined_admin' );
		}
		catch ( Exception $e )
		{
			Output::i()->error( 'edit_no_perm_err', '2S136/12', 403, '' );
		}
	}
	
	/**
	 * Quick Edit Title
	 *
	 * @return	void
	 */
	public function ajaxEditTitle(): void
	{
		try
		{
			Session::i()->csrfCheck();
			
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			if ( !$item->canEditTitle() )
			{
				throw new RuntimeException;
			}
			
			$oldTitle = $item->mapped( 'title' );

			$maxLength	= Settings::i()->max_title_length ?: 255;
			/* @var $databaseColumnMap array */
			$titleField	= $item::$databaseColumnMap['title'];

			if( mb_strlen( Request::i()->newTitle ) > $maxLength )
			{
				throw new LengthException( Member::loggedIn()->language()->addToStack( 'form_maxlength', FALSE, array( 'pluralize' => array( $maxLength ) ) ) );
			}
			elseif( !trim( Request::i()->newTitle ) )
			{
				throw new InvalidArgumentException('form_required');
			}

			$newTitle = new Text( 'newTitle', Request::i()->newTitle );

			if( $newTitle->validate() !== TRUE )
			{
				throw new InvalidArgumentException( Member::loggedIn()->language()->addToStack( 'form_tags_not_allowed', FALSE, array( 'sprintf' => array( Request::i()->newTitle ) ) ) );
			}

			/* Stop doing anything if the title wasn't changed */
			if( $item->$titleField == $newTitle->value )
			{
				Output::i()->json( $item->$titleField );
			}

			$item->$titleField = $newTitle->value;
			$idField = $item::$databaseColumnId;
			$item->save();

			/* rebuild the container last item data */
			if ( isset( $item::$containerNodeClass ) )
			{
				$item->container()->setLastComment();
				$item->container()->save();
			}

			if( SearchContent::isSearchable( $item ) )
			{
				if( $item::$firstCommentRequired )
				{
					$class = $item->firstComment();
				}
				else
				{
					$class = $item;
				}
				Index::i()->index( $class );
			}
			
			Session::i()->modLog( 'modlog__comment_edit_title', array( (string) $item->url() => FALSE, $item->$titleField => FALSE, $oldTitle => FALSE ), $item );
			
			Output::i()->json( $item->$titleField );
		}
		catch( LogicException $e )
		{
			Output::i()->error( $e->getMessage(), '2S136/1M', 403, '' );
		}
		catch ( Exception $e )
		{
			Output::i()->error( 'node_error', '2S136/11', 404, '' );
		}
	}
	
	/**
	 * Set the breadcrumb and title
	 *
	 * @param Item $item	Content item
	 * @param	bool				$link	Link the content item element in the breadcrumb
	 * @return	void
	 */
	protected function _setBreadcrumbAndTitle( Item $item, bool $link=TRUE ): void
	{
		$container	= NULL;
		try
		{
			$container = $item->container();
			if ( IPS::classUsesTrait( $container, 'IPS\Content\ClubContainer' ) and $club = $container->club() )
			{
				FrontNavigation::$clubTabActive = TRUE;
				Output::i()->breadcrumb = array();
				Output::i()->breadcrumb[] = array( Url::internal( 'app=core&module=clubs&controller=directory', 'front', 'clubs_list' ), Member::loggedIn()->language()->addToStack('module__core_clubs') );
				Output::i()->breadcrumb[] = array( $club->url(), $club->name );
				Output::i()->breadcrumb[] = array( $container->url(), $container->_title );
				
				if ( Settings::i()->clubs_header == 'sidebar' )
				{
					Output::i()->sidebar['contextual'] = Theme::i()->getTemplate( 'clubs', 'core' )->header( $club, $container, 'sidebar' );
				}
			}
			else
			{
				foreach ( $container->parents() as $parent )
				{
					Output::i()->breadcrumb[] = array( $parent->url(), $parent->_title );
				}
				Output::i()->breadcrumb[] = array( $container->url(), $container->_title );
			}
		}
		catch ( Exception $e ) { }
		Output::i()->breadcrumb[] = array( $link ? $item->url() : NULL, $item->mapped( 'title' ) );

		$title = ( isset( Request::i()->page ) and Request::i()->page > 1 ) ? Member::loggedIn()->language()->addToStack( 'title_with_page_number', FALSE, array( 'sprintf' => array( $item->mapped( 'title' ), Request::i()->page ) ) ) : $item->mapped( 'title' );
		Output::i()->title = $container ? ( $title . ' - ' . $container->_title ) : $title;
	}
	
	/**
	 * Toggle a poll status
	 *
	 * @return void
	 */
	protected function pollStatus(): void
	{
		try
		{
			Session::i()->csrfCheck();
						
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			
			if ( !IPS::classUsesTrait( $class, 'IPS\Content\Polls' ) )
			{
				throw new DomainException;
			}
			
			if ( $poll = $item->getPoll() )
			{
				if ( !$poll->canClose() )
				{
					Output::i()->error( 'no_module_permission', '2S136/Z', 403, '' );
				}

				$newStatus = ( Request::i()->value == 1 ? 0 : 1 );
				$poll->poll_closed = $newStatus;
				$redirectMessage = $newStatus == 0 ? 'poll_status_opened' : 'poll_status_closed';

				/* If opening the poll (after it has closed) remove the auto-close date */
				if( !$newStatus and ( $poll->poll_close_date instanceof DateTime ) )
				{
					$poll->poll_close_date = -1;
					$redirectMessage .= '_no_date';
				}

				$poll->save();

				$type = $poll->poll_closed ? 'closed' : 'opened';
				Session::i()->modLog( 'modlog__poll_' . $type, array( $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );

				/* Fire an event */
				Event::fire( 'onStateChange', $poll, [ $type ] );

				Output::i()->redirect( $item->url(), $redirectMessage );
			}
			else
			{
				throw new UnderflowException;
			}
		}
		catch ( Exception $e )
		{
			Output::i()->error( 'node_error', '2S136/Y', 404, '' );
		}
	}
	
	/**
	 * Moderate
	 *
	 * @return	void
	 */
	protected function moderate(): void
	{
		try
		{
			Session::i()->csrfCheck();
						
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );

			/* @var Item $item */
			if ( $item::$hideLogKey and Request::i()->action === 'hide' )
			{
				/* If this is an AJAX request, and we're coming from the approval queue, just do it. */
				if ( Request::i()->isAjax() AND isset( Request::i()->_fromApproval ) )
				{
					$item->modAction( Request::i()->action );
					Output::i()->json( 'OK' );
				}
				
				$this->_setBreadcrumbAndTitle( $item );
				
				$form = new Form;
				$form->add( new Text( 'hide_reason' ) );
				$this->moderationAlertField($form, $item);
				if ( $values = $form->values() )
				{
					$item->modAction( Request::i()->action, NULL, $values['hide_reason'] );

					if( isset( $values['moderation_alert_content']) AND $values['moderation_alert_content'])
					{
						$this->sendModerationAlert($values, $item);
					}
				}
				else
				{
					Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
					return;
				}
			}
			else if ( Request::i()->action === 'delete' AND isset( Request::i()->immediate ) )
			{
				$item->modAction( Request::i()->action, NULL, NULL, TRUE );
			}
			else
			{
				if( Request::i()->action === 'lock' )
				{
					if ( Member::loggedIn()->modPermission('can_manage_alerts') AND $item->author()->member_id )
					{
						$form = new Form;
						$this->moderationAlertField($form, $item);
						$this->_setBreadcrumbAndTitle( $item );

						if ( $values = $form->values() )
						{
							if( isset( $values['moderation_alert_content']) AND $values['moderation_alert_content'])
							{
								$this->sendModerationAlert($values, $item);
							}
						}
						else
						{
							Output::i()->bypassCsrfKeyCheck = TRUE;
							Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
							return;
						}
					}

				}

				$item->modAction( Request::i()->action );
			}
			
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( 'OK' );
			}
			else
			{
				if( Request::i()->action == 'delete' )
				{
					try
					{
						if( Member::loggedIn()->modPermission( 'can_view_reports' ) AND isset( Request::i()->_report ) )
						{
							try
							{
								$report = Report::loadAndCheckPerms( Request::i()->_report );
								Output::i()->redirect( $report->url() );
							}
							catch( OutOfRangeException $e )
							{
								Output::i()->redirect( $item->container()->url() );
							}
						}
						else
						{
							Output::i()->redirect( $item->container()->url() );
						}
					}
					catch( BadMethodCallException $e )
					{
						/* We could be deleting something from a report, in which case we can go back to the report */
						if( Member::loggedIn()->canAccessReportCenter() AND isset(  Request::i()->_report ) )
						{
							Output::i()->redirect( Url::internal( 'app=core&module=modcp&controller=modcp&tab=reports&action=view&id=' . Request::i()->_report , 'front', 'modcp_report' ) );
						}
						else
						{
							/* Generic fallback in case we delete something that doesn't have a container */
							Output::i()->redirect( Url::internal('') );
						}
					}
				}
				else
				{
					Output::i()->redirect( $item->url(), 'mod_confirm_' . Request::i()->action );
				}
			}
		}
		catch( InvalidArgumentException $e )
		{
			Output::i()->error( 'mod_error_invalid_action', '3S136/1A', 403, 'mod_error_invalid_action_admin' );
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/1', 404, '' );
		}
	}
	
	/**
	 * Move
	 *
	 * @return	void
	 */
	protected function move(): void
	{
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			if ( !$item->canMove() )
			{
				throw new DomainException;
			}

			$container = $item->container();
			
			$form = new Form( 'form', Member::loggedIn()->language()->addToStack( 'move_send_to_container', FALSE, array( 'sprintf' => $container->_title ) ), NULL, array( 'data-bypassValidation' => true ) );
			$form->actionButtons[] = Theme::i()->getTemplate( 'forms', 'core', 'global' )->button( Member::loggedIn()->language()->addToStack( 'move_send_to_item', FALSE, array( 'sprintf' => $item->definiteArticle() ) ), 'submit', null, 'ipsButton ipsButton--text', array( 'value' => 'item', 'name' => 'returnto' ) );
			$form->class = 'ipsForm--vertical ipsForm--move';
			$form->add( new Node( 'move_to', NULL, TRUE, array(
				'class'				=> get_class( $item->container() ),
				'permissionCheck'	=> function( $node ) use ( $item )
				{
					if ( $node->id != $item->container()->id )
					{
						try
						{
							/* If the item is in a club, only allow moving to other clubs that you moderate */
							if ( IPS::classUsesTrait( $item->container(), 'IPS\Content\ClubContainer' ) and $item->container()->club()  )
							{
								return $item::modPermission( 'move', Member::loggedIn(), $node ) and $node->can( 'add' ) ;
							}
							
							if ( $node->can( 'add' ) )
							{
								return true;
							}
						}
						catch( OutOfBoundsException $e ) { }
					}
					
					return false;
				},
				'clubs'	=> TRUE
			) ) );

			$this->moderationAlertField( $form, $item);
			
			if ( isset( $class::$databaseColumnMap['moved_to'] ) )
			{
				$form->add( new Checkbox( 'move_keep_link' ) );
				
				if ( Settings::i()->topic_redirect_prune )
				{
					Member::loggedIn()->language()->words['move_keep_link_desc'] = Member::loggedIn()->language()->addToStack( '_move_keep_link_desc', FALSE, array( 'pluralize' => array( Settings::i()->topic_redirect_prune ) ) );
				}
			}

			if ( $values = $form->values() )
			{
				if ( $values['move_to'] === NULL OR !$values['move_to']->can( 'add' ) OR $values['move_to']->id == $item->container()->id )
				{
					Output::i()->error( 'node_move_invalid', '1S136/L', 403, '' );
				}

				/* If this item is read, we need to re-mark it as such after moving */
				if( IPS::classUsesTrait( $item, 'IPS\Content\ReadMarkers' ) )
				{
					$unread = $item->unread();
				}

				$item->move( $values['move_to'], $values['move_keep_link'] ?? FALSE );

				/* Mark it as read */
				if( IPS::classUsesTrait( $item, 'IPS\Content\ReadMarkers' ) and $unread == 0 )
				{
					$item->markRead( NULL, NULL, NULL, TRUE );
				}
				if( isset( $values['moderation_alert_content']) AND $values['moderation_alert_content'] )
				{
					$this->sendModerationAlert($values, $item);
				}
					$eventName = str_replace( '\\', '', substr( get_class($item), 3 ) ) . '_modaction';
					Webhook::fire( $eventName, ['action' => 'move', 'item' => $item->apiOutput()], $item->webhookFilters() );

				Session::i()->modLog( 'modlog__action_move', array( $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) ?: ( method_exists( $item, 'item' ) ? $item->item()->mapped( 'title' ) : NULL ) => FALSE ),  $item );

				Output::i()->redirect( ( isset( Request::i()->returnto ) AND Request::i()->returnto == 'item' ) ? $item->url() : $container->url() );
			}
			
			$this->_setBreadcrumbAndTitle( $item );
			Output::i()->title = Member::loggedIn()->language()->addToStack( 'move_item', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( $class::$title ) ) ) );
			Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
			
		}
		catch ( Exception $e )
		{
			Output::i()->error( 'node_error', '2S136/D', 403, '' );
		}
	}
	
	/**
	 * Merge
	 *
	 * @return	void
	 */
	protected function merge(): void
	{
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			if ( !$item->canMerge() )
			{
				throw new DomainException;
			}

			$form = $item->mergeForm();

			if ( $values = $form->values() )
			{
				$target = $class::loadFromUrl( $values['merge_with'] );
				if ( !$target->canView() )
				{
					throw new DomainException;
				}

				$item->mergeIn( array( $target ), $values['move_keep_link'] ?? FALSE );
				Output::i()->redirect( $item->url() );
			}
			
			$this->_setBreadcrumbAndTitle( $item );
			Output::i()->title = Member::loggedIn()->language()->addToStack( 'merge_item', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( $class::$title ) ) ) );
				
			Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
		}
		catch ( Exception $e )
		{
			Output::i()->error( 'node_error', '2S136/G', 403, '' );
		}
	}
	
	/**
	 * Delete a report
	 *
	 * @return	void
	 */
	protected function deleteReport(): void
	{
		Session::i()->csrfCheck();

		/* Make sure the user confirmed the deletion */
		Request::i()->confirmedDelete();

		try
		{
			$report = Db::i()->select( '*', 'core_rc_reports', array( 'id=? AND report_by=? AND date_reported > ?', Request::i()->cid, Member::loggedIn()->member_id, time() - ( Settings::i()->automoderation_report_again_mins * 60 ) ) )->first();
		}
		catch( UnderflowException $e )
		{
			Output::i()->error( 'automoderation_cannot_find_report', '2S136/1G', 404, '' );
		}
		
		try
		{
			$index = Report::load( $report['rid'] );
		}
		catch( OutofRangeException $e )
		{
			Output::i()->error( 'automoderation_cannot_find_report', '2S136/1H', 404, '' );
		}
		
		$class = $index->class;
		
		Db::i()->delete( 'core_rc_reports', array( 'id=?', Request::i()->cid ) );
		
		/* Recalculate, we may have dropped below the threshold needed to hide a thing */
		$index->runAutomaticModeration();
		
		Output::i()->redirect( $class::load( $index->content_id )->url(), 'automoderation_deleted' );
	}

	/**
	 * View Edit Log of the Item
	 *
	 * @return	void
	 * @throws	LogicException
	 */
	public function editlog(): void
	{
		/* Permission check */
		if ( Settings::i()->edit_log != 2 or ( !Settings::i()->edit_log_public and !Member::loggedIn()->modPermission( 'can_view_editlog' ) ) )
		{
			throw new DomainException;
		}

		try
		{
			/* Init */
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );

			$this->_setBreadcrumbAndTitle( $item );

			/* Even if guests can see the changelog, we don't want this being indexed in Google */
			Output::i()->metaTags['robots'] = 'noindex';
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/1Q', 404, '' );
		}

		$idColumn = $class::$databaseColumnId;
		$where = array( array( 'class=? AND comment_id=?', $class, $item->$idColumn ) );
		if ( !Member::loggedIn()->modPermission( 'can_view_editlog' ) )
		{
			$where[] = array( '`member`=? AND public=1', $item->author()->member_id );
		}

		$table = new TableDb( 'core_edit_history', $item->url()->setQueryString( array( 'do' => 'editlog' ) ), $where );
		$table->sortBy = 'time';
		$table->sortDirection = 'desc';
		$table->limit = 10;
		$table->tableTemplate = array( Theme::i()->getTemplate( 'global', 'core', 'front' ), 'commentEditHistoryTable' );
		$table->rowsTemplate = array( Theme::i()->getTemplate( 'global', 'core', 'front' ), 'commentEditHistoryRows' );
		$table->parsers = array(
			'new' => function( $val )
			{
				return $val;
			},
			'old' => function( $val )
			{
				return $val;
			}
		);
		$table->extra = $item;

		$pageParam = $table->getPaginationKey();
		if( Request::i()->isAjax() AND isset( Request::i()->$pageParam ) )
		{
			Output::i()->sendOutput( (string) $table );
		}

		Output::i()->breadcrumb[] = array( NULL, Member::loggedIn()->language()->addToStack( 'edit_history_title' ) );
		Output::i()->title = Member::loggedIn()->language()->addToStack( 'edit_history_title' );
		Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'front' )->commentEditHistory( (string) $table, $item );
	}
	
	/**
	 * Report Item
	 *
	 * @return	void
	 */
	protected function report(): void
	{
		try
		{
			/* Init */
			$class = static::$contentModel;
			$commentClass = $class::$commentClass;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			
			/* Permission check */
			$canReport = $item->canReport();
			if ( $canReport !== TRUE AND !( $canReport == 'report_err_already_reported' AND Settings::i()->automoderation_enabled ) )
			{
				Output::i()->error( $canReport, '2S136/6', 403, '' );
			}
			
			/* Show form */
			$form = new Form( NULL, 'report_submit' );
			$form->class = 'ipsForm--vertical ipsForm--report';
			$idColumn = $class::$databaseColumnId;
			
			/* As we group by user id to determine if max points have been reached, guests cannot contribute to counts */
			if ( Member::loggedIn()->member_id and Settings::i()->automoderation_enabled )
			{
				/* Has this member already reported this in the past 24 hours */
				try
				{
					$index = Report::loadByClassAndId( get_class( $item ), $item->$idColumn );
					$report = Db::i()->select( '*', 'core_rc_reports', array( 'rid=? and report_by=? and date_reported > ?', $index->id, Member::loggedIn()->member_id, time() - ( Settings::i()->automoderation_report_again_mins * 60 ) ) )->first();
					
					Output::i()->output = Theme::i()->getTemplate( 'system', 'core' )->reportedAlready( $index, $report, $item );
					return;
				}
				catch( Exception $e ) { }
				
				$options = array( Report::TYPE_MESSAGE => Member::loggedIn()->language()->addToStack('report_message_item') );
				foreach( Types::roots() as $type )
				{
					$options[ $type->id ] = $type->_title;
				}
				if ( count( $options ) > 1 )
				{
					$form->add( new Radio( 'report_type', NULL, FALSE, ['options' => $options] ) );
				}
			}
			
			$form->add( new Editor( 'report_message', NULL, FALSE, array( 'app' => 'core', 'key' => 'Reports', 'autoSaveKey' => "report-{$class::$application}-{$class::$module}-{$item->$idColumn}", 'minimize' => Request::i()->isAjax() ? 'report_message_placeholder' : NULL ) ) );
			if ( !Request::i()->isAjax() )
			{
				Member::loggedIn()->language()->words['report_message'] = Member::loggedIn()->language()->addToStack('report_message_fallback');
			}
			
			if( !Member::loggedIn()->member_id )
			{
				$form->add( new Captcha );
			}
			if ( $values = $form->values() )
			{
				$guestDetails = [];
				if ( ! Member::loggedIn()->member_id and Settings::i()->report_capture_guest_details )
				{
					$guestDetails = [ 'name' => $values['report_guest_name'], 'email' => $values['report_guest_email'] ];

					/* Has this guest already reported this? */
					try
					{
						$index = Db::i()->select( '*', 'core_rc_index', [ 'class=? and content_id=?', get_class( $item ), $item->$idColumn ] )->first();
						$report = Db::i()->select( '*', 'core_rc_reports', [ 'rid=? and guest_email=?', $index['id'], $values['report_guest_email'] ] )->first();

						Output::i()->error( 'report_err_already_reported', '2S136/11', 403, '' );
					}
					catch( UnderflowException )
					{
						/* The query failed, so this is a new report */
					}
				}

				$report = $item->report( $values['report_message'], ( isset( $values['report_type'] ) ) ? $values['report_type'] : 0, null, $guestDetails );
				File::claimAttachments( "report-{$class::$application}-{$class::$module}-{$item->$idColumn}", $report->id );
				if ( Request::i()->isAjax() )
				{
					Output::i()->sendOutput( Member::loggedIn()->language()->addToStack( 'report_submit_success' ) );
				}
				else
				{
					Output::i()->redirect( $item->url(), 'report_submit_success' );
				}
			}

			$this->_setBreadcrumbAndTitle( $item );

			/* Even if guests can report something, we don't want the report form indexed in Google */
			Output::i()->metaTags['robots'] = 'noindex';

			Output::i()->output = Request::i()->isAjax() ? $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) ) : Theme::i()->getTemplate( 'system', 'core' )->reportForm( $form );
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/7', 404, '' );
		}
	}
	
	/**
	 * Get Next Unread Item
	 *
	 * @return	void
	 */
	protected function nextUnread(): void
	{
		try
		{
			$class		= static::$contentModel;
			$item		= $class::loadAndCheckPerms( Request::i()->id );
			$next		= $item->nextUnread();

			if ( $next instanceof Item)
			{
				Output::i()->redirect( $next->url()->setQueryString( array( 'do' => 'getNewComment' ) ) );
			}
		}
		catch( Exception $e )
		{
			Output::i()->error( 'next_unread_not_found', '2S136/J', 404, '' );
		}
	}
	
	/**
	 * React
	 *
	 * @return	void
	 */
	protected function react(): void
	{
		if( !IPS::classUsesTrait( static::$contentModel, 'IPS\Content\Reactable' ) )
		{
			Output::i()->error( 'node_error', '2S136/1N', 404, '' );
		}

		try
		{
			Session::i()->csrfCheck();
			
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$reaction = Reaction::load( Request::i()->reaction );
			$item->react( $reaction );

			/* Send a realtime event so that users on this page immediately see the reaction */
			if ( Bridge::i()->featureIsEnabled( 'realtime' ) )
			{
				$blurb = ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item, true );
				Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $blurb );
				Bridge::i()->publishRealtimeEvent( 'reaction-count', [
					'memberReacted' => Member::loggedIn()->member_id,
					'content' => $item->getDataLayerProperties(),
					'count' => count( $item->reactions() ),
					'score' => $item->reactionCount(),
					'blurb' => $blurb,
					'eventType' => 'item.react'
				]);
			}

			if ( Request::i()->isAjax() )
			{
				$output = array(
					'status' => 'ok',
					'count' => count( $item->reactions() ),
					'score' => $item->reactionCount(),
					'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item )
				);

				if ( DataLayer::enabled() )
				{
					$output['datalayer'] = array_replace( $item->getDataLayerProperties(), ['reaction_type' => $reaction->_title] );
				}

				Output::i()->json( $output );
			}
			else
			{
				if ( DataLayer::enabled() )
				{
					DataLayer::i()->addEvent( 'content_react', array_replace( $item->getDataLayerProperties(), [ 'reaction_type' => $reaction->_title ] ) );
				}

				Output::i()->redirect( $item->url() );
			}
		}
		catch( OutOfRangeException | DomainException $e )
		{
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array( 'error' => Member::loggedIn()->language()->addToStack( $e->getMessage() ) ), 403 );
			}
			else
			{
				Output::i()->error( $e->getMessage(), '1S136/14', 403, '' );
			}
		}
	}
	
	/**
	 * Unreact
	 *
	 * @return	void
	 */
	protected function unreact(): void
	{
		if( !IPS::classUsesTrait( static::$contentModel, 'IPS\Content\Reactable' ) )
		{
			Output::i()->error( 'node_error', '2S136/1O', 404, '' );
		}

		try
		{
			Session::i()->csrfCheck();

			$member = ( isset( Request::i()->member ) and Member::loggedIn()->modPermission('can_remove_reactions') ) ? Member::load( Request::i()->member ) : Member::loggedIn();
			
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$item->removeReaction( $member );

			/* Send a realtime event so that users on this page immediately see the reaction */
			if ( Bridge::i()->featureIsEnabled( 'realtime' ) )
			{
				$blurb = ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item, true );
				Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $blurb );
				Bridge::i()->publishRealtimeEvent( 'reaction-count', [
					'memberReacted' => Member::loggedIn()->member_id,
					'content' => $item->getDataLayerProperties(),
					'count' => count( $item->reactions() ),
					'score' => $item->reactionCount(),
					'blurb' => $blurb,
					'eventType' => 'item.unreact'
				]);
			}

			/* Log */
			if( $member->member_id !== Member::loggedIn()->member_id )
			{
				Session::i()->modLog( 'modlog__reaction_delete', array( $member->url()->__toString() => FALSE, $member->name => FALSE, $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );
			}

			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array(
					'status' => 'ok',
					'count' => count( $item->reactions() ),
					'score' => $item->reactionCount(),
					'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item )
				));
			}
			else
			{
				Output::i()->redirect( $item->url() );
			}
		}
		catch( DomainException $e )
		{
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array( 'error' => Member::loggedIn()->language()->addToStack( $e->getMessage() ) ), 403 );
			}
			else
			{
				Output::i()->error( $e->getMessage(), '1S136/15', 403, '' );
			}
		}
	}

	/**
	 * Get the reaction blurb for the content item
	 *
	 * @return void
	 */
	protected function reactionBlurb(): void
	{
		if ( !IPS::classUsesTrait( static::$contentModel, 'IPS\Content\Reactable' ) )
		{
			Output::i()->json( ['error' => 'reactions_not_available'], 404 );
		}
		$class = static::$contentModel;
		try
		{
			$item = $class::loadAndCheckPerms( Request::i()->id );
			Output::i()->json( [ 'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item ) ] );
		}
		catch ( OutOfRangeException )
		{
			Output::i()->json( [ 'error' => 'no_permission' ], 403 );
		}
	}

	/**
	 * Show Reactions
	 *
	 * @return    void
	 * @throws Exception
	 */
	protected function showReactions(): void
	{
		if( !IPS::classUsesTrait( static::$contentModel, 'IPS\Content\Reactable' ) )
		{
			Output::i()->error( 'node_error', '2S136/1P', 404, '' );
		}
		
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );

            $reactionId = isset( Request::i()->reaction ) ? (int) Request::i()->reaction : null;
			
			if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and !empty( $reactionId ) )
			{
				$reaction = Reaction::load( $reactionId );
				
				$numberToShowInPopup = 10;
				$where = $item->getReactionWhereClause( $reaction );
				$total = Db::i()->select( 'COUNT(*)', 'core_reputation_index', $where )->join( 'core_reactions', 'reaction=reaction_id' )->first();
				$names = Db::i()->select( 'name', 'core_reputation_index', $where, 'rep_date DESC', $numberToShowInPopup )->join( 'core_reactions', 'reaction=reaction_id' )->join( 'core_members', 'core_reputation_index.member_id=core_members.member_id' );
				
				Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core', 'front' )->reactionTooltip( $reaction, $total ? $names : [], ( $total > $numberToShowInPopup ) ? ( $total - $numberToShowInPopup ) : 0 ) );
			}
			else
			{		
				$blurb = $item->reactBlurb();
				
				$this->_setBreadcrumbAndTitle( $item );
				
				Output::i()->title = Member::loggedIn()->language()->addToStack( 'see_who_reacted' ) . ' - ' . Output::i()->title;
				
				$tabs = array();
				$tabs['all'] = array( 'title' => Member::loggedIn()->language()->addToStack('all'), 'count' => count( $item->reactions() ) );
				foreach(Reaction::roots() AS $reaction )
				{
					if ( $reaction->_enabled !== FALSE )
					{
						$tabs[ $reaction->id ] = array( 'title' => $reaction->_title, 'icon' => $reaction->_icon, 'count' => $blurb[$reaction->id] ?? 0 );
					}
				}

                $activeTab = $reactionId ?: 'all';
				
				$url = $item->url('showReactions');
				$url = $url->setQueryString( 'changed', 1 );

				if ( isset( Request::i()->item ) )
				{
					$url = $url->setQueryString( 'item', Request::i()->item );
				}
				
				if ( $activeTab !== 'all' )
				{
					$url = $url->setQueryString( 'reaction', $activeTab );
				}
	
				Output::i()->metaTags['robots'] = 'noindex';
				
				if ( Reaction::isLikeMode() or ( Request::i()->isAjax() AND isset( Request::i()->changed ) ) )
				{
					Output::i()->output = $item->reactionTable( $activeTab !== 'all' ? $activeTab : NULL, $url, 'reaction', FALSE );
				}
				else
				{
					Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'front' )->reactionTabs( $tabs, $activeTab, $item->reactionTable( $activeTab !== 'all' ? $activeTab : NULL ), $url, 'reaction', FALSE );
				}
			}
		}
		catch( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/18', 404, '' );
		}
		catch( DomainException $e )
		{
			Output::i()->error( 'no_module_permission', '2S136/19', 403, '' );
		}
	}
	
	/**
	 * Moderator Log
	 *
	 * @return	void
	 */
	protected function modLog(): void
	{
		if( !Member::loggedIn()->modPermission( 'can_view_moderation_log' ) )
		{
			Output::i()->error( 'no_module_permission', '2S136/1F', 403, '' );
		}
		
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );

			/* Set up some stuff so we're not doing too much logic / assignment in the template */
			$modlog = $item->moderationTable();

			Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'front' )->modLog( $item, $modlog );
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/T', 404, '' );
		}
		catch( DomainException $e )
		{
			Output::i()->error( 'no_module_permission', '2S136/U', 403, '' );
		}
	}

	/**
	 * Moderation Log
	 *
	 * @return	void
	 */
	protected function analytics() : void
	{
		if( !Member::loggedIn()->modPermission( 'can_view_moderation_log' ) )
		{
			Output::i()->error( 'no_module_permission', '2S136/1F', 403, '' );
		}

		try
		{
			/* @var Item $class */
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );

			/* Set up some stuff so we're not doing too much logic / assignment in the template */
			$lastCommenter = $members = $busy = $reacted = $images = NULL;
			try
			{
				$lastCommenter = $item->lastCommenter();
				if ( !$lastCommenter->member_id )
				{
					$lastCommenter = NULL;
				}
			}
			catch( BadMethodCallException $e ) { }

			if ( IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
			{
				$members	= $item->topPosters();
				$busy		= $item->popularDays();
				$reacted	= $item->topReactedPosts();
				$images		= $item->imageAttachments();
			}

			$commentCount = $item->commentCount();
			if( $class::$firstCommentRequired )
			{
				$commentCount--;
			}

			/* Set navigation */
			$this->_setBreadcrumbAndTitle( $item );

			Output::i()->breadcrumb[] = array( NULL, Member::loggedIn()->language()->addToStack( 'analytics_and_stats' ) );
			Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'front' )->analytics( $item, $lastCommenter, $members, $busy, $reacted, $images, $commentCount );
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/T', 404, '' );
		}
		catch( DomainException $e )
		{
			Output::i()->error( 'no_module_permission', '2S136/U', 403, '' );
		}
	}
	
	/**
	 * Go to new comment.
	 *
	 * @return	void
	 */
	public function getNewComment(): void
	{
		try
		{
			$class	= static::$contentModel;
			$item	= $class::loadAndCheckPerms( Request::i()->id );

			$timeLastRead = ( IPS::classUsesTrait( $item, ReadMarkers::class ) ) ? $item->timeLastRead() : null;

			if ( $timeLastRead instanceof DateTime )
			{
				$comment = NULL;
				if( DateTime::ts( $item->mapped('date') ) < $timeLastRead )
				{
					$comment = $item->comments( 1, NULL, 'date', 'asc', NULL, NULL, $timeLastRead );
				}

				/* If we don't have any unread comments... */
				if ( !$comment and $class::$firstCommentRequired )
				{
					/* If we haven't read the item at all, go there */
					if ( $item->unread() )
					{
						Output::i()->redirect( $item->url() );
					}
					/* Otherwise, go to the last comment */
					else
					{
						$comment = $item->comments( 1, NULL, 'date', 'desc' );
					}
				}

				Output::i()->redirect( $comment ? $comment->url() : $item->url() );
			}
			else
			{
				if ( IPS::classUsesTrait( $item, ReadMarkers::class ) and $item->unread() )
				{
					/* If we do not have a time last read set for this content, fallback to the reset time */
					$resetTimes = Member::loggedIn()->markersResetTimes( $class::$application );

					if ( ( is_array( $resetTimes ) AND array_key_exists( $item->container()->_id, $resetTimes ) ) and $item->mapped('date') < $resetTimes[ $item->container()->_id ] )
					{
						$comment = $item->comments( 1, NULL, 'date', 'asc', NULL, NULL, DateTime::ts( $resetTimes[ $item->container()->_id ] ) );
						
						if ( $class::$firstCommentRequired and $comment->isFirst() )
						{
							Output::i()->redirect( $item->url() );
						}
						
						Output::i()->redirect( $comment ? $comment->url() : $item->url() );
					}
					else
					{
						Output::i()->redirect( $item->url() );
					}
				}
				else
				{
					Output::i()->redirect( $item->url() );
				}
			}
		}
		catch( BadMethodCallException $e )
		{
			Output::i()->error( 'node_error', '2S136/I', 404, '' );
		}
		catch( OutOfRangeException $e )
		{
			$class = static::$contentModel;

			try
			{
				$item = $class::load( Request::i()->id );
				$error = ( !$item->canView() and ( $item->containerWrapper( TRUE ) and method_exists( $item->container(), 'errorMessage' ) ) ) ? $item->container()->errorMessage() : 'node_error';
			}
			catch( OutOfRangeException $e )
			{
				$error = 'node_error';
			}
			
			Output::i()->error( $error, '2S136/V', 403, '' );
		}
		catch( LogicException $e )
		{
			$class = static::$contentModel;

			try
			{
				$item = $class::load( Request::i()->id );
				$error = ( !$item->canView() and ( $item->containerWrapper( TRUE ) and method_exists( $item->container(), 'errorMessage' ) ) ) ? $item->container()->errorMessage() : 'node_error';
			}
			catch( OutOfRangeException $e )
			{
				$error = 'node_error';
			}

			Output::i()->error( $error, '2S136/R', 404, '' );
		}
	}
	
	/**
	 * Go to last comment
	 *
	 * @return	void
	 */
	public function getLastComment(): void
	{
		try
		{
			$class	= static::$contentModel;
			$item	= $class::loadAndCheckPerms( Request::i()->id );
			
			$comment = $item->comments( 1, NULL, 'date', 'desc' );
			
			if ( $comment !== NULL )
			{
				$this->_find( get_class( $comment ), $comment, $item );
			}
			else
			{
				Output::i()->redirect( $item->url() );
			}
		}
		catch( BadMethodCallException $e )
		{
			try
			{
				$item = $class::load( Request::i()->id );
				$error = ( !$item->canView() and ( $item->containerWrapper( TRUE ) and method_exists( $item->container(), 'errorMessage' ) ) ) ? $item->container()->errorMessage() : 'node_error';
			}
			catch( OutOfRangeException $e )
			{
				$error = 'node_error';
			}
			
			Output::i()->error( $error, '2S136/K', 404, '' );
		}
		catch( LogicException $e )
		{
			try
			{
				$item = $class::load( Request::i()->id );
				$error = ( !$item->canView() and ( $item->containerWrapper( TRUE ) and method_exists( $item->container(), 'errorMessage' ) ) ) ? $item->container()->errorMessage() : 'node_error';
			}
			catch( OutOfRangeException $e )
			{
				$error = 'node_error';
			}
			
			Output::i()->error( $error, '2S136/Q', 403, '' );
		}
	}
	
	/**
	 * Go to first comment
	 *
	 * @return	void
	 */
	public function getFirstComment(): void
	{
		try
		{
			$class	= static::$contentModel;
			$item	= $class::loadAndCheckPerms( Request::i()->id );
			
			if ( $class::$firstCommentRequired )
			{
				$comments = $item->comments( 2, NULL, 'date', 'asc' );
				$comment  = array_pop( $comments );
				unset( $comments );
			}
			else
			{
				$comment = $item->comments( 1, NULL, 'date', 'asc' );
			}
			
			if ( $comment !== NULL )
			{
				$this->_find( get_class( $comment ), $comment, $item );
			}
			else
			{
				Output::i()->redirect( $item->url() );
			}
		}
		catch( BadMethodCallException $e )
		{
			Output::i()->error( 'node_error', '2S136/W', 404, '' );
		}
		catch( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/X', 403, '' );
		}
	}
	
	/**
	 * Rate Review as helpful/unhelpful
	 *
	 * @return	void
	 */
	public function rateReview(): void
	{
		try
		{
			Session::i()->csrfCheck();
			
			/* Only logged in members */
			if ( !Member::loggedIn()->member_id )
			{
				throw new DomainException;
			}
			
			/* Init */
			$class = static::$contentModel;
			$reviewClass = $class::$reviewClass;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$review = $reviewClass::load( Request::i()->review );
			
			/* Review authors can't rate their own reviews */
			if ( $review->author()->member_id === Member::loggedIn()->member_id )
			{
				throw new DomainException;
			}
			
			/* Have we already rated? */
			/* @var $databaseColumnMap array */
			$dataColumn = $reviewClass::$databaseColumnMap['votes_data'];
			$votesData = $review->mapped( 'votes_data' ) ? json_decode( $review->mapped( 'votes_data' ), TRUE ) : array();
			if ( array_key_exists( Member::loggedIn()->member_id, $votesData ) )
			{
				Output::i()->error( 'you_have_already_rated', '2S136/A', 403, '' );
			}
			
			/* Add it */
			$votesData[ Member::loggedIn()->member_id ] = intval( Request::i()->helpful );
			if ( Request::i()->helpful )
			{
				$helpful = $reviewClass::$databaseColumnMap['votes_helpful'];
				$review->$helpful++;
			}
			$total = $reviewClass::$databaseColumnMap['votes_total'];
			$review->$total++;
			$review->$dataColumn = json_encode( $votesData );
			$review->save();
			
			/* Boink */
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( $review->html() );
			}
			else
			{
				Output::i()->redirect( $review->url() );
			}
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/9', 404, '' );
		}
	}

	/**
	 * Allow the author of a content item to reply to a review
	 *
	 * @return	void
	 */
	protected function _respond(): void
	{
		try
		{
			/* Init */
			$class = static::$contentModel;
			$reviewClass = $class::$reviewClass;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$review = $reviewClass::loadAndCheckPerms( Request::i()->review );

			/* Are we allowed to respond? */
			$editing = str_starts_with( Request::i()->do, 'edit' );
			if( $editing === TRUE )
			{
				if ( !$review->canEditResponse() )
				{
					throw new DomainException;
				}
			}
			else
			{
				if ( !$review->canRespond() )
				{
					throw new DomainException;
				}
			}
			
			$form = new Form;
			$form->add( new Editor( 'reviewResponse', $editing ? $review->mapped('author_response') : NULL, TRUE, array(
				'app'			=> 'core',
				'key'			=> 'ReviewResponses',
				'autoSaveKey' 	=> 'reviewResponse-' . $class::$application . '/' . $class::$module . '-' . Request::i()->review,
				'attachIds'		=> array( Request::i()->id, Request::i()->review, get_class( $item ) )
			) ) );
			
			if ( $values = $form->values() )
			{
				$review->setResponse( $values['reviewResponse'] );

				/* Claim attachments and clear the editor */
				File::claimAttachments( 'reviewResponse-' . $class::$application . '/' . $class::$module . '-' . Request::i()->review, Request::i()->id, Request::i()->review, get_class( $item ) );
				
				Output::i()->redirect( $review->url() );
			}

			Output::i()->metaTags['robots'] = 'noindex';
			
			Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/1I', 403, '' );
		}
	}

	/**
	 * Allow the author of a content item to edit their review response
	 *
	 * @return	void
	 */
	protected function _editResponse(): void
	{
		$this->_respond();
	}

	/**
	 * Delete a review response
	 *
	 * @return	void
	 */
	protected function _deleteResponse(): void
	{
		Session::i()->csrfCheck();

		try
		{
			/* Init */
			$class = static::$contentModel;
			$reviewClass = $class::$reviewClass;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$review = $reviewClass::loadAndCheckPerms( Request::i()->review );
			
			/* Are we allowed to delete responses? */
			if ( !$review->canDeleteResponse() )
			{
				throw new DomainException;
			}
			
			$review->author_response = NULL;
			$review->save();

			Output::i()->redirect( $review->url() );
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/1J', 403, '' );
		}
	}
	
	/**
	 * Message Form
	 *
	 * @return	void
	 */
	protected function messageForm(): void
	{
		$class = static::$contentModel;
		try
		{
			$item = $class::loadAndCheckPerms( Request::i()->id );

			$current = NULL;
			$metaData = $item->getMeta();
			if ( isset( Request::i()->meta_id ) AND isset( $metaData['core_ContentMessages'][ Request::i()->meta_id ] ) )
			{
				$current = $metaData['core_ContentMessages'][ Request::i()->meta_id ];
			}
			
			if ( !$item->canOnMessage( $current ? 'edit' : 'add' ) )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/1D', 404, '' );
		} 

		$form = new Form;
		$form->attributes['data-controller'] = 'core.front.core.contentMessage';
		$form->add( new YesNo( 'message_is_public', $current['is_public'] ?? FALSE ) );
		$form->add( new Editor( 'message', $current ? $current['message'] : NULL , TRUE, array( 'app' => 'core', 'key' => 'Meta', 'autoSaveKey' => $current ? "meta-message-" . Request::i()->meta_id : "meta-message-new", 'attachIds' => $current ? array( Request::i()->meta_id, NULL, 'core_ContentMessages' ) : NULL ) ) );
		$form->add( new Custom( 'message_color', $current['color'] ?? 'none', FALSE, array( 'getHtml' => function( $element )
        {
            return Theme::i()->getTemplate( 'forms', 'core', 'front' )->colorSelection( $element->name, $element->value );
        } ), NULL, NULL, NULL, 'message_color' ) );

		if ( $values = $form->values() )
		{
			if ( $current )
			{
				$item->editMessage( Request::i()->meta_id, $values['message'], $values['message_color'], NULL, (bool) $values['message_is_public'] );
				File::claimAttachments( "meta-message-" . Request::i()->meta_id, Request::i()->meta_id, NULL, 'core_ContentMessages' );
				
				Session::i()->modLog( 'modlog__message_edit', array(
					(string) $item->url()	=> FALSE,
					$item->mapped('title')	=> FALSE
				), $item );
			}
			else
			{
				$id = $item->addMessage( $values['message'], $values['message_color'], NULL, (bool) $values['message_is_public'] );
				File::claimAttachments( "meta-message-new", $id, NULL, 'core_ContentMessages' );
				
				Session::i()->modLog( 'modlog__message_add', array(
					(string) $item->url()	=> FALSE,
					$item->mapped('title')	=> FALSE
				), $item );
			}
			
			Output::i()->redirect( $item->url() );
		}
		
		Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
	}
	
	/**
	 * Message Delete
	 *
	 * @return	void
	 */
	protected function messageDelete(): void
	{
		Session::i()->csrfCheck();
		
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			if ( !$item->canOnMessage('delete') )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/1E', 404, '' );
		}
		
		$item->deleteMessage( Request::i()->meta_id );
		
		File::unclaimAttachments( 'core_Meta', Request::i()->meta_id, NULL, 'core_ContentMessages' );
		
		Session::i()->modLog( 'modlog__message_delete', array(
			(string) $item->url()	=> FALSE,
			$item->mapped('title')	=> FALSE
		), $item );
		
		if ( Request::i()->isAjax() )
		{
			Output::i()->json( 'OK' );
		}
		else
		{
			Output::i()->redirect( $item->url() );
		}
	}

	/**
	 * Solve the item
	 *
	 * @return	void
	 */
	public function solve(): void
	{
		Session::i()->csrfCheck();

		try
		{
			$class = static::$contentModel;
			$commentClass = $class::$commentClass;
			$item = $class::loadAndCheckPerms( Request::i()->id );

			if ( ! IPS::classUsesTrait( $item, 'IPS\Content\Solvable' ) )
			{
				throw new OutOfRangeException;
			}

			if ( ! $item->canSolve() )
			{
				throw new OutOfRangeException;
			}

			$comment = $commentClass::loadAndCheckPerms( Request::i()->answer );
			$idField = $comment::$databaseColumnId;

			if ( $comment->item() != $item )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/7', 404, '' );
		}

		$item->toggleSolveComment( Request::i()->answer, TRUE );

		/* Log */
		if ( Member::loggedIn()->modPermission('can_set_best_answer') )
		{
			Session::i()->modLog( 'modlog__best_answer_set', array( $comment->$idField => FALSE ), $item );
		}

		Output::i()->redirect( $item->url() );
	}

	/**
	 * Mark the comment helpful
	 *
	 * @return	void
	 */
	public function toggleHelpful(): void
	{
		Session::i()->csrfCheck();

		if( !IPS::classUsesTrait( static::$contentModel, 'IPS\Content\Helpful' ) )
		{
			Output::i()->error( 'node_error', '@todo', 404, '' );
		}

		try
		{
			/* Load comment */
			$class = static::$contentModel;
			$commentClass = $class::$commentClass;
			$comment = $commentClass::loadAndCheckPerms( Request::i()->answer );

			if( $comment->markedHelpful() )
			{
				$comment->unmarkHelpful();
			}
			else
			{
				$comment->markHelpful();
			}

			if ( Request::i()->isAjax() )
			{
				$mostHelpfulHtml = null;
				if( $mostHelpful = $comment->item()->helpfulPosts(1) and $mostHelpful[0]['count'] >= Settings::i()->forums_helpful_highlight )
				{
					$mostHelpfulHtml = Theme::i()->getTemplate( 'topics', 'forums', 'front' )->topicHelpfulMessage( $mostHelpful );
				}

				$output = array(
					'status' => 'ok',
					'count' => $comment->helpfulCount(),
					'helpfulReplies' => $comment->item()->helpfulsRepliesCount(),
					'mostHelpfulHtml' => $mostHelpfulHtml,
					'countLanguage' => Member::loggedin()->language()->addToStack('helpful_only_n_helpful', FALSE, array( 'pluralize' => [ $comment->item()->helpfulsRepliesCount() ] ) ),
					'button' => Theme::i()->getTemplate( 'system', 'core', 'front' )->helpfulButton( $comment, $comment->item() ),
				);

				Output::i()->json( $output );
			}
			else
			{
				Output::i()->redirect( $comment->url() );
			}
		}
		catch( OutOfRangeException | DomainException $e )
		{
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array( 'error' => Member::loggedIn()->language()->addToStack( $e->getMessage() ) ), 403 );
			}
			else
			{
				Output::i()->error( $e->getMessage(), '1S136/14', 403, '' );
			}
		}
	}

	/**
	 * Unsolve the item
	 *
	 * @return	void
	 */
	public function unsolve(): void
	{
		Session::i()->csrfCheck();

		try
		{
			$class = static::$contentModel;
			$commentClass = $class::$commentClass;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$comment = $commentClass::loadAndCheckPerms( Request::i()->answer );

			$idField = $comment::$databaseColumnId;
			/* @var $databaseColumnMap array */
			$solvedField = $item::$databaseColumnMap['solved_comment_id'];

			if ( ! IPS::classUsesTrait( $item, 'IPS\Content\Solvable' ) )
			{
				throw new OutOfRangeException;
			}

			if ( ! $item->canSolve() )
			{
				throw new OutOfRangeException;
			}

			if ( $comment->item() != $item )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/G', 404, '' );
		}

		if ( $item->mapped('solved_comment_id') )
		{
			try
			{
				$item->toggleSolveComment( $item->mapped('solved_comment_id'), FALSE );

				if ( Member::loggedIn()->modPermission('can_set_best_answer') )
				{
					Session::i()->modLog( 'modlog__best_answer_unset', array( $comment->$idField => FALSE ), $item );
				}
			}
			catch ( Exception $e ) {}
		}

		Output::i()->redirect( $item->url() );
	}
	
	/**
	 * Toggle Item Moderation
	 *
	 * @return	void
	 */
	public function toggleItemModeration(): void
	{
		Session::i()->csrfCheck();
		
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			
			if ( !$item->canToggleItemModeration() )
			{
				throw new BadMethodCallException;
			}
			
			if ( $item->itemModerationEnabled() )
			{
				$action = 'disable';
				$actionLang = 'disabled';
			}
			else
			{
				$action = 'enable';
				$actionLang = 'enabled';
			}
			
			$item->toggleItemModeration( $action );
			
			Session::i()->modLog( 'modlog__item_moderation_toggled', [$actionLang => TRUE], $item );
			
			Output::i()->redirect( $item->url(), $actionLang );
		}
		catch( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/1T', 404, '' );
		}
		catch( BadMethodCallException | InvalidArgumentException $e )
		{
			Output::i()->error( 'no_module_permission', '2S136/1U', 403, '' );
		}
	}

	/**
	 * Stuff that applies to both comments and reviews
	 *
	 * @param	string	$method	Desired method
	 * @param	array	$args	Arguments
	 */
	public function __call( string $method, array $args )
	{
		$class = static::$contentModel;
		
		try
		{
			$item = $class::loadAndCheckPerms( Request::i()->id );
			
			$comment = NULL;
			if ( mb_substr( $method, -7 ) === 'Comment' )
			{
				if ( isset( $class::$commentClass ) and ( isset( Request::i()->comment ) OR mb_substr( $method, 0, 8 ) === 'multimod' ) )
				{
					$class = $class::$commentClass;
					$method = '_' . mb_substr( $method, 0, mb_strlen( $method ) - 7 );
					
					$comment = $method === '_multimod' ? NULL : $class::load( Request::i()->comment );
				}
			}
			elseif ( mb_substr( $method, -6 ) === 'Review' )
			{
				if ( isset( $class::$reviewClass ) and ( isset( Request::i()->review ) OR mb_substr( $method, 0, 8 ) === 'multimod' ) )
				{
					$class = $class::$reviewClass;
					$method = '_' . mb_substr( $method, 0, mb_strlen( $method ) - 6 );
					$comment = $method === '_multimod' ? NULL : $class::load( Request::i()->review );
				}
			}
			
			if ( $method === '_multimod' )
			{
				$this->_multimod( $class, $item );
			}
									
			if ( !$comment or !method_exists( $this, $method ) )
			{
				if ( mb_substr( $method, 0, 4 ) === 'find' )
				{
					/* Nothing found, redirect to main URL */
					Output::i()->redirect( $item->url() );
				}
				else
				{
					Output::i()->error( 'page_not_found', '2S136/B', 404, '' );
				}
			}
			else
			{
				$this->$method( $class, $comment, $item );
			}
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/C', 404, '' );
		}
	}
	
	/**
	 * Find a Comment / Review (do=findComment/findReview)
	 *
	 * @param	string					$commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 */
	public function _find( string $commentClass, Comment $comment, Item $item ) : void
	{
		/* @var Comment $commentClass */
		$idColumn = $commentClass::$databaseColumnId;
		/* @var $databaseColumnMap array */
		$itemColumn = $commentClass::$databaseColumnMap['item'];
		
		/* Note in the session that we were looking for this comment. This can be used
			to set appropriate meta tag descriptions */
		$_SESSION['_findComment']	= $comment->$idColumn;
		
		/* Work out where the comment is in the item */	
		$directional = ( in_array( 'IPS\Content\Review', class_parents( $commentClass ) ) ) ? '>=?' : '<=?';
		$where = array(
			array( $commentClass::$databasePrefix . $itemColumn . '=?', $comment->$itemColumn ),
			array( $commentClass::$databasePrefix . $idColumn . $directional, $comment->$idColumn )
		);

		/* Exclude content pending deletion, as it will not be shown inline  */
		if ( isset( $commentClass::$databaseColumnMap['approved'] ) )
		{
			$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['approved'] . '<>?', -2 );
			$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['approved'] . '<>?', -3 );
		}
		elseif( isset( $commentClass::$databaseColumnMap['hidden'] ) )
		{
			$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] . '<>?', -2 );
			$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] . '<>?', -3 );
		}

		if ( $commentClass::findCommentWhere() !== NULL )
		{
			$where[] = $commentClass::findCommentWhere();
		}
		if ( $container = $item->containerWrapper() )
		{
			if ( $commentClass::modPermission( 'view_hidden', NULL, $container ) === FALSE )
			{
				if ( isset( $commentClass::$databaseColumnMap['approved'] ) )
				{
					$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['approved'] . '=?', 1 );
				}
				elseif( isset( $commentClass::$databaseColumnMap['hidden'] ) )
				{
					$where[] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['hidden'] . '=?', 0 );
				}
			}
		}
		$commentPosition = $commentClass::db()->select( 'COUNT(*) AS position', $commentClass::$databaseTable, $where )->first();
		
		/* Now work out what page that makes it */
		$url = $item->url();

		if ( in_array( 'IPS\Content\Review', class_parents( $commentClass ) ) )
		{
			$perPage = $item::$reviewsPerPage;
		}
		else
		{
			$perPage = $item::getCommentsPerPage();
		}

		$page = ceil( $commentPosition / $perPage );
		if ( $page != 1 )
		{
			$url = $url->setPage( 'page', $page );
		}

		if( $commentClass::$tabParameter !== NULL )
		{
			$url = $url->setQueryString( $commentClass::$tabParameter );
		}

		$fragment = 'comment';

		if ( in_array( 'IPS\Content\Review', class_parents( $commentClass ) ) )
		{
			$url = $url->setQueryString( array( 'sort' => 'newest' ) );
			$fragment = 'review';
		}

		if ( isset( Request::i()->showDeleted ) )
		{
			$url = $url->setQueryString( 'showDeleted', 1 );
		}
		
		if ( isset( Request::i()->_report ) )
		{
			$url = $url->setQueryString( '_report', Request::i()->_report );
		}
		
		/* And redirect */
		Output::i()->redirect( $url->setFragment( $fragment . '-' . $comment->$idColumn ) );
	}
	
	/**
	 * Hide Comment/Review
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	public function _hide( string $commentClass, Comment $comment, Item $item  ): void
	{
		/* If this is an AJAX request, and we're coming from the approval queue, just do it. */
		if ( Request::i()->isAjax() AND isset( Request::i()->_fromApproval ) )
		{
			Session::i()->csrfCheck();

			$comment->modAction( 'hide' );
			Output::i()->json( 'OK' );
		}
		
		if ( $comment::$hideLogKey )
		{
			$form = new Form;
			$form->add( new Text( 'hide_reason' ) );
			$this->moderationAlertField( $form, $comment);

			if ( $values = $form->values() )
			{
				$comment->modAction( 'hide', NULL, $values['hide_reason'] );

				if( isset( $values['moderation_alert_content']) AND $values['moderation_alert_content'])
				{
					$this->sendModerationAlert($values, $comment);
				}
			}
			else
			{
				$this->_setBreadcrumbAndTitle( $item );
				Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
				return;
			}
		}
		else
		{
			Session::i()->csrfCheck();

			$comment->modAction( 'hide' );
		}
		
		Output::i()->redirect( $comment->url() );
	}
	
	/**
	 * Unhide Comment/Review
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	public function _unhide( string $commentClass, Comment $comment, Item $item  ): void
	{
		Session::i()->csrfCheck();
		$comment->modAction( 'unhide' );

		if ( Request::i()->isAjax() )
		{
			Output::i()->sendOutput( $comment->html() );
		}
		else
		{
			Output::i()->redirect( $comment->url() );
		}
	}
	
	/**
	 * Restore a Comment / Review
	 *
	 * @param	string					$commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _restore( string $commentClass, Comment $comment, Item $item ): void
	{
		Session::i()->csrfCheck();
		
		if ( isset( Request::i()->restoreAsHidden ) )
		{
			Session::i()->modLog( 'modlog__action_restore_hidden', array(
				$comment::$title				=> TRUE,
				$comment->url()->__toString()	=> FALSE
			) );

			$comment->modAction( 'restoreAsHidden' );
		}
		else
		{
			Session::i()->modLog( 'modlog__action_restore', array(
				$comment::$title				=> TRUE,
				$comment->url()->__toString()	=> FALSE
			) );

			$comment->modAction( 'restore' );
		}
		
		if ( isset( Request::i()->_report ) )
		{
			try
			{
				$report = Report::loadAndCheckPerms( Request::i()->_report );
				Output::i()->redirect( $report->url() );
			}
			catch( OutOfRangeException $e )
			{
				Output::i()->redirect( $comment->url() );
			}
		}
		else
		{
			Output::i()->redirect( $comment->url() );
		}
	}


	/**
	 * Edit Comment/Review
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _edit( string $commentClass, Comment $comment, Item $item ) : void
	{
		$class = static::$contentModel;
		/* @var $databaseColumnMap array */
		/* @var Comment $commentClass */
		$valueField = $commentClass::$databaseColumnMap['content'];
		$idField = $commentClass::$databaseColumnId;
		$itemIdField = $item::$databaseColumnId;

		if ( $comment->canEdit() )
		{
			$form = new Form( 'form', false );
			$form->class = 'ipsForm--vertical ipsForm--edit';
			
			if ( in_array( 'IPS\Content\Review', class_parents( $comment ) ) )
			{
				$ratingField = $commentClass::$databaseColumnMap['rating'];
				$form->add( new Rating( 'rating_value', $comment->$ratingField, TRUE, array( 'max' => Settings::i()->reviews_rating_out_of ) ) );
			}
			
			$form->add( new Editor( 'comment_value', $comment->$valueField, TRUE, array(
				'app'			=> $class::$application,
				'key'			=> IPS::mb_ucfirst( $class::$module ),
				'autoSaveKey' 	=> 'editComment-' . $class::$application . '/' . $class::$module . '-' . $comment->$idField,
				'attachIds'		=> $comment->attachmentIds()
			) ) );
			
			/* Post Anonymously */
			$container = $item->containerWrapper();
			if ( $container and $container->canPostAnonymously( $container::ANON_COMMENTS ) and IPS::classUsesTrait( $comment, 'IPS\Content\Anonymous' ) and ( $comment->author() and $comment->author()->group['gbw_can_post_anonymously'] or $comment->isAnonymous() ) )
			{
				$form->add ( new YesNo( 'post_anonymously', $comment->isAnonymous(), FALSE, array( 'label' => Member::loggedIn()->language()->addToStack( 'post_anonymously_suffix' ) ), NULL, NULL, NULL, 'post_anonymously' ) );
			}

			foreach( $comment->ui( 'formElements', array( $comment->item() ), TRUE ) as $element )
			{
				$form->add( $element );
			}

			$form->addButton( 'cancel', 'link', $item->url()->setQueryString( in_array( 'IPS\Content\Review', class_parents( $comment ) ) ? array( 'do' => 'findReview', 'review' => $comment->$idField ) : array( 'do' => 'findComment', 'comment' => $comment->$idField ) ), 'ipsButton ipsButton--text ipsButton--form-cancel', array( 'data-action' => 'cancelEditComment', 'data-comment-id' => $comment->$idField ) );

			$form->addButton( 'save', 'submit', null, 'ipsButton ipsButton--positive ipsButton--form-submit', array() );

			if ( IPS::classUsesTrait( $comment, 'IPS\Content\EditHistory' ) and Settings::i()->edit_log )
			{
				if ( Settings::i()->edit_log == 2 or isset( $commentClass::$databaseColumnMap['edit_reason'] ) )
				{
					$form->add( new Text( 'comment_edit_reason', ( isset( $commentClass::$databaseColumnMap['edit_reason'] ) ) ? $comment->mapped( 'edit_reason' ) : NULL, FALSE, array( 'maxLength' => 255 ) ) );
				}
				if ( Member::loggedIn()->group['g_append_edit'] )
				{
					$form->add( new Checkbox( 'comment_log_edit', FALSE ) );
				}
			}
			
			if ( $values = $form->values() )
			{
                Event::fire( 'onBeforeCreateOrEdit', $comment, array( $values ) );

				/* Log History */
				if ( IPS::classUsesTrait( $commentClass, 'IPS\Content\EditHistory' ) and Settings::i()->edit_log )
				{
					$comment->logEdit( $values );
				}

				/* Determine if the comment is hidden to start with */
				$isHidden = $comment->hidden();
				
				/* Do it */
				$comment->editContents( $values['comment_value'] );

                /* Edit rating */
				$reloadPage = false;
				if ( isset( $values['rating_value'] ) and in_array( 'IPS\Content\Review', class_parents( $comment ) ) )
				{
					/* The star rating changes but is outside of JS scope to change when editing a comment */
					$ratingField = $comment::$databaseColumnMap['rating'];
					if ( $comment->$ratingField != $values['rating_value'] )
					{
						$reloadPage = true;
					}
					
					$comment->editRating( $values['rating_value'] );
				}

				/* Anonymous posting */
				if( isset( $values[ 'post_anonymously' ] ) AND IPS::classUsesTrait( $comment, 'IPS\Content\Anonymous' ) )
				{
					/* The anon changes to the user details (photo, name, etc) is outside the JS scope to change when editing a comment */
					if ( $comment->isAnonymous() !== (bool) $values['post_anonymously'] )
					{
						$reloadPage = true;
					}
					
					$comment->setAnonymous( $values[ 'post_anonymously' ], $comment->author() );
				}

				Event::fire( 'onCreateOrEdit', $comment, array( $values ) );

				$comment->ui( 'formPostSave', array( $values ) );
			
				/* Moderator log */
				Session::i()->modLog( 'modlog__comment_edit', array( $comment->url()->__toString() => FALSE, $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );

				/* Add a data layer event indicating this was updated */
				if ( DataLayer::enabled( 'analytics_full' ) and $comment::dataLayerEventActive( 'content_edit' ) )
				{
					DataLayer::i()->addEvent( 'content_edit', $comment->getDataLayerProperties() );
				}

				/* If this is an AJAX request and the comment hidden status has not changed just output the comment HTML */
				if ( Request::i()->isAjax() AND $isHidden == $comment->hidden() AND $reloadPage === false )
				{
					/* Make sure we get the latest data from the database tables to prevent replication lag issues */
					try
					{
						$comment->clearCaches();
						$comment = $commentClass::constructFromData( Db::i()->select( '*', $commentClass::$databaseTable, [ $commentClass::$databasePrefix . $idField . '=?', $comment->$idField ], flags: Db::SELECT_FROM_WRITE_SERVER )->first() );

						Output::i()->output = $comment->html();
						return;
					}
					catch( OutOfRangeException | UnderflowException $e )
					{
						/* Something went wrong so redirect */
						Output::i()->redirect( $comment->url() );
					}
				}
				else
				{
					Output::i()->redirect( $comment->url() );
				}
			}
			
			$this->_setBreadcrumbAndTitle( $item );
			Output::i()->breadcrumb[] = array( NULL, in_array( 'IPS\Content\Review', class_parents( $commentClass ) )? Member::loggedIn()->language()->addToStack( 'edit_review' ) : Member::loggedIn()->language()->addToStack( 'edit_comment' ) );
			Output::i()->title = Member::loggedIn()->language()->addToStack( 'edit_comment' ) . ' - ' . $item->mapped( 'title' );
			Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
		}
		else
		{
			throw new InvalidArgumentException;
		}
	}
	
	/**
	 * Delete Comment/Review
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _delete( string $commentClass, Comment $comment, Item $item ) : void
	{
		Session::i()->csrfCheck();

		$currentPageCount = $item->commentPageCount();

		/* @var Comment $commentClass */
		/* @var $databaseColumnMap array */
		$valueField = $commentClass::$databaseColumnMap['content'];
		$idField = $commentClass::$databaseColumnId;
		
		if ( $item::$firstCommentRequired and $comment->mapped( 'first' ) )
		{
			if ( $item->canDelete() )
			{
				/* 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( $item, 'IPS\Content\Hideable' ) AND !isset( Request::i()->immediately ) )
				{
					$item->logDelete();
				}
			}
			else
			{
				Output::i()->error( 'node_noperm_delete', '3S136/1L', 403, '' );
			}
		}
		else
		{
			if ( $comment->canDelete() )
			{
				/* 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( $item, 'IPS\Content\Hideable' ) AND !isset( Request::i()->immediately ) )
				{
					$comment->logDelete();
				}
				else
				{
					$comment->delete();
				}
				
				/* Log */
				Session::i()->modLog( 'modlog__comment_delete', array( $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );
			}
			else
			{
				Output::i()->error( 'node_noperm_delete', '3S136/1K', 403, '' );
			}
		}

		/* Reset best answer */
		if( $item->topic_answered_pid and $item->topic_answered_pid == $comment->$idField )
		{
			$item->topic_answered_pid = 0;
			$item->save();
		}

		/* This doesn't go through modAction, so we will add the datalayer event here */
		if ( $comment and DataLayer::enabled( 'analytics_full' ) and $comment::dataLayerEventActive( 'content_delete' ) )
		{
			DataLayer::i()->addEvent( 'content_delete', $comment->getDataLayerProperties() );
		}
		
		if ( Request::i()->isAjax() )
		{
			$currentPageCount = Request::i()->page;
			$newPageCount = $item->commentPageCount( TRUE );
			if ( isset( Request::i()->page ) AND $currentPageCount != $newPageCount )
			{
				/* If we are on page 2 and delete a comment, and there are 3 pages, we don't want to be sent to page 3 (that makes no sense).
					Instead, we'll send you to the page requested. If it exists you'll be on the same page. If it doesn't, the controller will
					handle sending you to the correct location */
				Output::i()->json( array( 'type' => 'redirect', 'total' => $item->mapped( 'num_comments' ), 'url' => (string) $item->url()->setPage( 'page', (int) Request::i()->page ) ) );
			}
			else
			{
				Output::i()->json( array( 'page' => $newPageCount, 'total' => $item->mapped( 'num_comments' ) ) );
			}
		}
		else
		{
			if ( isset( Request::i()->_report ) )
			{
				try
				{
					$report = Report::loadAndCheckPerms( Request::i()->_report );
					Output::i()->redirect( $report->url() );
				}
				catch( OutOfRangeException $e )
				{
					Output::i()->redirect( $item->url() );
				}
			}
			else
			{
				Output::i()->redirect( $item->url() );
			}
		}
	}
	
	/**
	 * Split Comment
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _split( string $commentClass, Comment $comment, Item $item ) : void
	{
		if ( $comment->canSplit() )
		{
			/* @var Item $itemClass */
			$itemClass = $comment::$itemClass;
			$idColumn = $itemClass::$databaseColumnId;
			$commentIdColumn = $comment::$databaseColumnId;
			
			/* Create a copy of the old item for logging */
			$oldItem = $item;
			
			/* Construct a form */
			$form = $this->_splitForm( $item, $comment );

			/* Handle submissions */
			if ( $values = $form->values() )
			{
				/* Are we creating or using an existing? */
				if ( isset( $values['_split_type'] ) and $values['_split_type'] === 'new' )
				{
					$item = $itemClass::createItem( $comment->author(), $comment->mapped( 'ip_address' ), DateTime::ts( $comment->mapped( 'date' ) ), $values[$itemClass::$formLangPrefix . 'container'] ?? NULL );
					$item->processForm( $values );
					if ( isset( $itemClass::$databaseColumnMap['first_comment_id'] ) )
					{
						$firstCommentIdColumn = $itemClass::$databaseColumnMap['first_comment_id'];
						$item->$firstCommentIdColumn = $comment->$commentIdColumn;
					}

					/* Does the first post require moderator approval? */
					if ( $comment->hidden() === 1 )
					{
						if ( isset( $item::$databaseColumnMap['hidden'] ) )
						{
							$column = $item::$databaseColumnMap['hidden'];
							$item->$column = 1;
						}
						elseif ( isset( $item::$databaseColumnMap['approved'] ) )
						{
							$column = $item::$databaseColumnMap['approved'];
							$item->$column = 0;
						}
					}
					/* Or is it hidden? */
					elseif ( $comment->hidden() === -1 )
					{
						if ( isset( $item::$databaseColumnMap['hidden'] ) )
						{
							$column = $item::$databaseColumnMap['hidden'];
						}
						elseif ( isset( $item::$databaseColumnMap['approved'] ) )
						{
							$column = $item::$databaseColumnMap['approved'];
						}

						$item->$column = -1;
					}

					$item->save();

					if( $comment->hidden() !== 0 )
					{
						if ( isset( $comment::$databaseColumnMap['hidden'] ) )
						{
							$column = $comment::$databaseColumnMap['hidden'];
							$comment->$column = 0;
						}
						elseif ( isset( $comment::$databaseColumnMap['approved'] ) )
						{
							$column = $comment::$databaseColumnMap['approved'];
							$comment->$column = 1;
						}

						$comment->save();
					}
		
					Event::fire( 'onItemSplit', $item, array( $oldItem ) );
					$eventName = str_replace( '\\', '', substr( get_class($item), 3 ) ) . '_modaction';
					Webhook::fire( $eventName, ['action' => 'split', 'item' => $item->apiOutput()], $item->webhookFilters() );
				}
				else
				{
					$item = $itemClass::loadFromUrl( $values['_split_into_url'] );

					if ( !$item->canView() )
					{
						throw new DomainException;
					}
				}

				/* Remove featured comment associations */
				if( IPS::classUsesTrait( $itemClass, 'IPS\Content\MetaData' ) AND $comment->isFeatured() AND $oldItem )
				{
					Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->unfeatureComment( $oldItem, $comment );
				}

				/* Remove solved associations */
				if ( $oldItem and IPS::classUsesTrait( $oldItem, 'IPS\Content\Solvable' ) and $oldItem->isSolved() and ( $oldItem->mapped('solved_comment_id') === $comment->$commentIdColumn ) )
				{
					$oldItem->toggleSolveComment( $comment->$commentIdColumn, FALSE );
				}

				$comment->move($item);
				$oldItem->rebuildFirstAndLastCommentData();

				/* Log it */
				Session::i()->modLog( 'modlog__action_split', array(
					$item::$title					=> TRUE,
					$item->url()->__toString()		=> FALSE,
					$item->mapped( 'title' )			=> FALSE,
					$oldItem->url()->__toString()	=> FALSE,
					$oldItem->mapped( 'title' )		=> FALSE
				), $item );

				if( isset( $values['moderation_alert_content'] ) AND $values['moderation_alert_content'] )
				{
					$this->sendModerationAlert( $values, $comment );
				}
			
				/* Redirect to it */
				Output::i()->redirect( $item->url() );
			}
			
			/* Display */
			$this->_setBreadcrumbAndTitle( $item );
			Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
		}
		else
		{
			throw new DomainException;
		}
	}
	
	/**
	 * Edit Log
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	public function _editlog( string $commentClass, Comment $comment, Item $item ) : void
	{
		/* Permission check */
		if ( Settings::i()->edit_log != 2 or ( !Settings::i()->edit_log_public and !Member::loggedIn()->modPermission( 'can_view_editlog' ) ) )
		{
			throw new DomainException;
		}

		/* @var Comment $commentClass */
		$idColumn = $commentClass::$databaseColumnId;
		$where = array( array( 'class=? AND comment_id=?', $commentClass, $comment->$idColumn ) );
		if ( !Member::loggedIn()->modPermission( 'can_view_editlog' ) )
		{
			$where[] = array( '`member`=? AND public=1', $comment->author()->member_id );
		}

		$table = new TableDb( 'core_edit_history', $item->url()->setQueryString( array( 'do' => 'editlogComment', 'comment' => $comment->$idColumn ) ), $where );
		$table->sortBy = 'time';
		$table->sortDirection = 'desc';
		$table->limit = 10;
		$table->tableTemplate = array( Theme::i()->getTemplate( 'global', 'core', 'front' ), 'commentEditHistoryTable' );
		$table->rowsTemplate = array( Theme::i()->getTemplate( 'global', 'core', 'front' ), 'commentEditHistoryRows' );
		$table->parsers = array(
			'new' => function( $val )
			{
				return $val;
			},
			'old' => function( $val )
			{
				return $val;
			}
		);
		$table->extra = $comment;

		$pageParam = $table->getPaginationKey();
		if( Request::i()->isAjax() AND isset( Request::i()->$pageParam ) )
		{
			Output::i()->sendOutput( (string) $table );
		}
		
		/* Display */
		$container = NULL;
		try
		{
			$container = $item->container();
			foreach ( $container->parents() as $parent )
			{
				Output::i()->breadcrumb[] = array( $parent->url(), $parent->_title );
			}
			Output::i()->breadcrumb[] = array( $container->url(), $container->_title );
		}
		catch ( Exception $e ) { }
		Output::i()->breadcrumb[] = array( $comment->url(), $item->mapped( 'title' ) );
		Output::i()->breadcrumb[] = array( NULL, Member::loggedIn()->language()->addToStack( 'edit_history_title' ) );
		Output::i()->title = Member::loggedIn()->language()->addToStack( 'edit_history_title' );
		Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'front' )->commentEditHistory( (string) $table, $comment );
	}
	
	/**
	 * Report Comment/Review
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _report( string $commentClass, Comment $comment, Item $item ) : void
	{
		try
		{
			$class = static::$contentModel;

			/* Permission check */
			$canReport = $comment->canReport();
			if ( $canReport !== TRUE AND !( $canReport == 'report_err_already_reported' AND Settings::i()->automoderation_enabled ) )
			{
				Output::i()->error( $canReport, '2S136/4', 403, '' );
			}

			/* Show form */
			$form = new Form( 'report_submit', 'report_submit' );
			$form->class = 'ipsForm--vertical ipsForm--report';
			$itemIdColumn = $class::$databaseColumnId;
			$idColumn = $comment::$databaseColumnId;
			
			/* As we group by user id to determine if max points have been reached, guests cannot contribute to counts */
			if ( Member::loggedIn()->member_id and Settings::i()->automoderation_enabled )
			{
				/* Has this member already reported this in the past 24 hours */
				try
				{
					$index = Report::loadByClassAndId( get_class( $comment ), $comment->$idColumn );
					$report = Db::i()->select( '*', 'core_rc_reports', array( 'rid=? and report_by=? and date_reported > ?', $index->id, Member::loggedIn()->member_id, time() - ( Settings::i()->automoderation_report_again_mins * 60 ) ) )->first();
					
					Output::i()->output = Theme::i()->getTemplate( 'system', 'core' )->reportedAlready( $index, $report, $comment );
					return;
				}
				catch( Exception $e ) { }
			}

			$options = array( Report::TYPE_MESSAGE => Member::loggedIn()->language()->addToStack('report_message_comment') );
			foreach( Types::roots() as $type )
			{
				$options[ $type->id ] = $type->_title;
			}

			if ( count( $options ) > 1 )
			{
				$form->add( new Select( 'report_type', null, false, ['options' => $options] ) );
			}

			if ( ! Member::loggedIn()->member_id and Settings::i()->report_capture_guest_details )
			{
				/* Guest details */
				$form->add( new Text( 'report_guest_name', null, Settings::i()->report_guest_details_name_mandatory ) );
				$form->add( new Email( 'report_guest_email', null, true ) );
			}

			$form->add( new Editor( 'report_message', null, (bool) Settings::i()->report_content_mandatory, array( 'app' => 'core', 'key' => 'Reports', 'autoSaveKey' => "report-{$class::$application}-{$class::$module}-{$item->$itemIdColumn}-{$comment->$idColumn}", 'minimize' => Settings::i()->report_content_mandatory ? 'report_message_placeholder_mandatory' : 'report_message_placeholder' ) ) );

			if ( !Request::i()->isAjax() )
			{
				Member::loggedIn()->language()->words['report_message'] = Member::loggedIn()->language()->addToStack('report_message_fallback');
			}
			
			if( !Member::loggedIn()->member_id )
			{
				$form->add( new Captcha );
			}

			if ( $values = $form->values() )
			{
				$guestDetails = [];
				if ( ! Member::loggedIn()->member_id and Settings::i()->report_capture_guest_details )
				{
					$guestDetails = [ 'name' => $values['report_guest_name'], 'email' => $values['report_guest_email'] ];

					/* Has this guest already reported this? */
					try
					{
						$index = Db::i()->select( '*', 'core_rc_index', [ 'class=? and content_id=?', get_class( $comment ), $comment->$idColumn ] )->first();
						$report = Db::i()->select( '*', 'core_rc_reports', [ 'rid=? and guest_email=?', $index['id'], $values['report_guest_email'] ] )->first();

						Output::i()->error( 'report_err_already_reported', '2S136/11', 403, '' );
					}
					catch( UnderflowException )
					{
						/* The query failed, so this is a new report */
					}
				}

				$report = $comment->report( $values['report_message'], ( isset( $values['report_type'] ) ) ? $values['report_type'] : 0, null, $guestDetails  );
				File::claimAttachments( "report-{$class::$application}-{$class::$module}-{$item->$itemIdColumn}-{$comment->$idColumn}", $report->id );
				if ( Request::i()->isAjax() )
				{
					Output::i()->sendOutput( Member::loggedIn()->language()->addToStack( 'report_submit_success' ) );
				}
				else
				{
					Output::i()->redirect( $comment->url(), 'report_submit_success' );
				}
			}
			$this->_setBreadcrumbAndTitle( $item );

			/* Even if guests can report something, we don't want the report form indexed in Google */
			Output::i()->metaTags['robots'] = 'noindex';

			Output::i()->output = Request::i()->isAjax() ? $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) ) : Theme::i()->getTemplate( 'system', 'core' )->reportForm( $form );
		}
		catch ( LogicException $e )
		{
			Output::i()->error( 'node_error', '2S136/10', 404, '' );
		}
	}
	
	/**
	 * React to a comment/review
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _react( string $commentClass, Comment $comment, Item $item ): void
	{
		try
		{
			Session::i()->csrfCheck();

			$reaction = Reaction::load( Request::i()->reaction );
			$comment->react( $reaction );

			/* Send a realtime event so that users on this page immediately see the reaction */
			if ( Bridge::i()->featureIsEnabled( 'realtime' ) )
			{
				$blurb = ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment, true );
				Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $blurb );
				Bridge::i()->publishRealtimeEvent( 'reaction-count', [
					'memberReacted' => Member::loggedIn()->member_id,
					'content' => $comment->getDataLayerProperties(),
					'blurb' => $blurb,
					'score' => $comment->reactionCount(),
					'count' => count( $comment->reactions() ),
					'eventType' => 'comment.react'
				]);
			}

			if ( Request::i()->isAjax() )
			{
				$output = array(
					'status' => 'ok',
					'count' => count( $comment->reactions() ),
					'score' => $comment->reactionCount(),
					'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment )
				);

				if ( DataLayer::enabled() )
				{
					$item = $item ?? $comment->item();
					$object = ( !isset( $item::$databaseColumnMap['content'] ) AND ( $item::$commentClass ) AND $comment->isFirst() ) ? $item : $comment;
					$output['datalayer'] = array_replace( $object->getDataLayerProperties(), ['reaction_type' => $reaction->_title] );
				}


				Output::i()->json( $output );
			}
			else
			{
				/* Data Layer Event */
				if ( DataLayer::enabled() )
				{
					$properties = $comment->getDataLayerProperties();
					$properties['reaction_type'] = $reaction->_title;

					DataLayer::i()->addEvent( 'content_react', $properties );
				}
				Output::i()->redirect( $comment->url() );
			}
		}
		catch( DomainException $e )
		{
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array( 'error' => Member::loggedIn()->language()->addToStack( $e->getMessage() ) ), 403 );
			}
			else
			{
				Output::i()->error( $e->getMessage(), '1S136/16', 403, '' );
			}
		}
	}
	
	/**
	 * Unreact to a comment/review
	 *
	 * @param	string					$commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _unreact( string $commentClass, Comment $comment, Item $item ): void
	{
		try
		{
			Session::i()->csrfCheck();

			$member = ( isset( Request::i()->member ) and Member::loggedIn()->modPermission('can_remove_reactions') ) ? Member::load( Request::i()->member ) : Member::loggedIn();

			$comment->removeReaction( $member );

			/* Send a realtime event so that users on this page immediately see the reaction */
			if ( Bridge::i()->featureIsEnabled( 'realtime' ) )
			{
				$blurb = ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment, true );
				Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $blurb );
				Bridge::i()->publishRealtimeEvent( 'reaction-count', [
					'memberReacted' => Member::loggedIn()->member_id,
					'content' => $comment->getDataLayerProperties(),
					'blurb' => $blurb,
					'score' => $comment->reactionCount(),
					'count' => count( $comment->reactions() ),
					'eventType' => 'comment.unreact'
				]);
			}

			/* Log */
			if( $member->member_id !== Member::loggedIn()->member_id )
			{
				Session::i()->modLog( 'modlog__comment_reaction_delete', array( $member->url()->__toString() => FALSE, $member->name => FALSE, $comment->url()->__toString() => FALSE, $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );
			}
			
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array(
					'status' => 'ok',
					'count' => count( $comment->reactions() ),
					'score' => $comment->reactionCount(),
					'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment )
				));
			}
			else
			{
				Output::i()->redirect( $comment->url() );
			}
		}
		catch( DomainException $e )
		{
			if ( Request::i()->isAjax() )
			{
				Output::i()->json( array( 'error' => Member::loggedIn()->language()->addToStack( $e->getMessage() ) ), 403 );
			}
			else
			{
				Output::i()->error( $e->getMessage(), '1S136/17', 403, '' );
			}
		}
	}

	/**
	 * Get the reaction blurb for a comment (output in a JSON Response)
	 *
	 * @param string $commentClass
	 * @param Comment $comment
	 * @param Item $item
	 *
	 * @return void
	 */
	protected function _reactionBlurb( string $commentClass, Comment $comment, Item $item ): void
	{
		$blurb = ( Settings::i()->reaction_count_display === 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment );
		Output::i()->json([ 'blurb' => $blurb ]);
	}
	
	/**
	 * Show Comment/Review Reactions
	 *
	 * @param string $commentClass	The comment/review class
	 * @param Comment $comment		The comment/review
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _showReactions( string $commentClass, Comment $comment, Item $item ): void
	{
		/* @var Comment $commentClass */
		$idColumn = $commentClass::$databaseColumnId;

        $reactionId = isset( Request::i()->reaction ) ? (int) Request::i()->reaction : null;
		if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and !empty( $reactionId ) )
		{
			$reaction = Reaction::load( $reactionId );
			
			$numberToShowInPopup = 10;
			$where = $comment->getReactionWhereClause( $reaction );
			$total = Db::i()->select( 'COUNT(*)', 'core_reputation_index', $where )->join( 'core_reactions', 'reaction=reaction_id' )->first();
			$names = Db::i()->select( 'name', 'core_reputation_index', $where, 'rep_date DESC', $numberToShowInPopup )->join( 'core_reactions', 'reaction=reaction_id' )->join( 'core_members', 'core_reputation_index.member_id=core_members.member_id' );
			
			Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core', 'front' )->reactionTooltip( $reaction, $total ? $names : [], ( $total > $numberToShowInPopup ) ? ( $total - $numberToShowInPopup ) : 0 ) );
		}
		else
		{		
			$blurb = $comment->reactBlurb();
	
			$this->_setBreadcrumbAndTitle( $item );
			Output::i()->title = Member::loggedIn()->language()->addToStack( 'see_who_reacted' ) . ' (' . $comment->$idColumn . ') - ' . Output::i()->title;
			
			$tabs = array();
			$tabs['all'] = array( 'title' => Member::loggedIn()->language()->addToStack('all'), 'count' => count( $comment->reactions() ) );
			foreach(Reaction::roots() AS $reaction )
			{
				if ( $reaction->_enabled !== FALSE )
				{
					$tabs[ $reaction->id ] = array( 'title' => $reaction->_title, 'icon' => $reaction->_icon, 'count' => $blurb[$reaction->id] ?? 0 );
				}
			}
	
			$activeTab = $reactionId ?: 'all';
			
			$url = $comment->url('showReactions');
			$url = $url->setQueryString( 'changed', 1 );
			
			if ( $activeTab !== 'all' )
			{
				$url = $url->setQueryString( 'reaction', $activeTab );
			}
	
			Output::i()->metaTags['robots'] = 'noindex';
			
			if ( Reaction::isLikeMode() or ( Request::i()->isAjax() AND isset( Request::i()->changed ) ) )
			{
				Output::i()->output = $comment->reactionTable( $activeTab !== 'all' ? $activeTab : NULL );
			}
			else
			{
				Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'front' )->reactionTabs( $tabs, $activeTab, $comment->reactionTable( $activeTab !== 'all' ? $activeTab : NULL ), $url, 'reaction', FALSE );
			}
		}
	}
	
	/**
	 * Multimod
	 *
	 * @param	string					$commentClass	The comment/review class
	 * @param Item $item			The item
	 * @return	void
	 * @throws	LogicException
	 */
	protected function _multimod( string $commentClass, Item $item ): void
	{
		/* @var Comment $commentClass */
		Session::i()->csrfCheck();
		
		$checkAgainst = Request::i()->modaction;
		if( !$checkAgainst )
		{
			throw new DomainException;
		}
		
		$classToCheck = $commentClass;
		if( $checkAgainst == 'split' OR $checkAgainst == 'merge' )
		{
			$classToCheck = $item;
			$checkAgainst = 'split_merge';
		}
		
		if ( !$classToCheck::modPermission( $checkAgainst, NULL, $item->containerWrapper() ) )
		{
			throw new DomainException;
		}
		
		if ( Request::i()->modaction == 'split' )
		{
			$form = $this->_splitForm( $item );
			$form->hiddenValues['modaction'] = 'split';
			foreach ( Request::i()->multimod as $k => $v )
			{
				$form->hiddenValues['multimod['.$k.']'] = $v;
			}
			if ( $values = $form->values() )
			{
				$itemIdColumn = $item::$databaseColumnId;
				$commentIdColumn = $commentClass::$databaseColumnId;

				/* Create a copy of the old item for logging */
				$oldItem = $item;
				/* @var $databaseColumnMap array */
				$comments = iterator_to_array( new ActiveRecordIterator( Db::i()->select(
					'*',
					$commentClass::$databaseTable,
					array(
						array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['item'] . '=?', $item->$itemIdColumn ),
						Db::i()->in( $commentClass::$databasePrefix . $commentClass::$databaseColumnId, array_keys( Request::i()->multimod ) )
					),
					$commentClass::$databasePrefix . $commentClass::$databaseColumnMap['date']
				), $commentClass ) );
				
				foreach ( $comments as $comment )
				{
					$firstComment = $comment;
					break;
				}

				/* If we don't have a $firstComment, something went wrong - perhaps the input multimod comment ids are all from a different topic for instance */
				if( !isset( $firstComment ) )
				{
					$form->error	= Member::loggedIn()->language()->addToStack( 'mod_error_invalid_action' );
					goto splitFormError;
				}
					
				if ( isset( $values['_split_type'] ) and $values['_split_type'] === 'new' )
				{
					$item = $item::createItem( $firstComment->author(), $firstComment->mapped( 'ip_address' ), DateTime::ts( $firstComment->mapped( 'date' ) ), $values[ $item::$formLangPrefix . 'container' ] );
					$item->processForm( $values );
					if ( isset( $item::$databaseColumnMap['first_comment_id'] ) )
					{
						$firstCommentIdColumn = $item::$databaseColumnMap['first_comment_id'];
						$item->$firstCommentIdColumn = $firstComment->$commentIdColumn;
					}

					/* Does the first post require moderator approval? */
					if ( $firstComment->hidden() === 1 )
					{
						if ( isset( $item::$databaseColumnMap['hidden'] ) )
						{
							$column = $item::$databaseColumnMap['hidden'];
							$item->$column = 1;
						}
						elseif ( isset( $item::$databaseColumnMap['approved'] ) )
						{
							$column = $item::$databaseColumnMap['approved'];
							$item->$column = 0;
						}
					}
					/* Or is it hidden? */
					elseif ( $firstComment->hidden() === -1 )
					{
						if ( isset( $item::$databaseColumnMap['hidden'] ) )
						{
							$column = $item::$databaseColumnMap['hidden'];
						}
						elseif ( isset( $item::$databaseColumnMap['approved'] ) )
						{
							$column = $item::$databaseColumnMap['approved'];
						}

						$item->$column = -1;
					}

					$item->save();

				}
				else
				{
					$item = $item::loadFromUrl( $values['_split_into_url'] );

					if ( !$item->canView() )
					{
						throw new DomainException;
					}
				}
				
				foreach ( $comments as $comment )
				{
					/* Remove featured comment associations */
					if( IPS::classUsesTrait( $item, 'IPS\Content\MetaData' ) AND $comment->isFeatured() AND $oldItem )
					{
						Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->unfeatureComment( $oldItem, $comment );
					}

					/* Remove solved associations */
					if ( $oldItem and IPS::classUsesTrait( $oldItem, 'IPS\Content\Solvable' ) and $oldItem->isSolved() and ( $oldItem->mapped('solved_comment_id') === $comment->$commentIdColumn ) )
					{
						$oldItem->toggleSolveComment( $comment->$commentIdColumn, FALSE );
					}

					if( $comment == $firstComment AND $comment->hidden() !== 0 )
					{
						if ( isset( $comment::$databaseColumnMap['hidden'] ) )
						{
							$column = $comment::$databaseColumnMap['hidden'];
							$comment->$column = 0;
						}
						elseif ( isset( $comment::$databaseColumnMap['approved'] ) )
						{
							$column = $comment::$databaseColumnMap['approved'];
							$comment->$column = 1;
						}
					}

					$comment->move( $item, TRUE );
				}

				$item->rebuildFirstAndLastCommentData();
				$oldItem->rebuildFirstAndLastCommentData();

				/* Option to do some post split stuff */
				if ( method_exists( $item, 'splitComplete' ) )
				{
					$item->splitComplete( $oldItem, $item, $comments );
				}

				/* Now reindex all of the comments - we do this last to prevent the "is last comment" flag continously bouncing around while the comments are still being moved */
				foreach( $comments as $comment )
				{
					/* Add to search index */
					if( SearchContent::isSearchable( $comment ) )
					{
						Index::i()->index( $comment );
					}
				}
				
				Index::i()->rebuildAfterMerge( $item );
				Index::i()->rebuildAfterMerge( $oldItem );
				
				/* Log it */
				Session::i()->modLog( 'modlog__action_split', array(
					$item::$title					=> TRUE,
					$item->url()->__toString()		=> FALSE,
					$item->mapped( 'title' )			=> FALSE,
					$oldItem->url()->__toString()	=> FALSE,
					$oldItem->mapped( 'title' )		=> FALSE
				), $item );

				Output::i()->redirect( $firstComment->url() );
			}
			else
			{
				/* Label for goto command */
				splitFormError:
				$this->_setBreadcrumbAndTitle( $item );
				Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
				
				if ( Request::i()->isAjax() )
				{
					Output::i()->sendOutput( Output::i()->output  );
				}
				else
				{
					Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core' )->globalTemplate( Output::i()->title, Output::i()->output, array( 'app' => Dispatcher::i()->application->directory, 'module' => Dispatcher::i()->module->key, 'controller' => Dispatcher::i()->controller ) ) );
				}
			}
		}
		elseif ( Request::i()->modaction == 'merge' )
		{
			if ( !( count( Request::i()->multimod ) > 1 ) )
			{
				Output::i()->error( 'cannot_merge_one_post', '1S136/S', 403, '' );
			}
			
			$comments	= array();
			$authors	= array();
			$content	= array();
			foreach( array_keys( Request::i()->multimod ) AS $id )
			{
				try
				{
					$comments[$id]	= $commentClass::loadAndCheckPerms( $id );
					$content[]		= $comments[$id]->mapped( 'content' );
				}
				catch( Exception $e ) {}
			}
			
			$form = new Form;
			$form->class = 'ipsForm--vertical ipsForm--merge';
			$form->add( new Editor( 'final_comment_content', implode( '<p>&nbsp;</p>', $content ), TRUE, array(
				'app'			=> $item::$application,
				'key'			=> ucwords( $item::$module ),
				'autoSaveKey'	=> 'mod-merge-' . implode( '-', array_keys( $comments ) ),
			) ) );

			if ( $values = $form->values() )
			{
				/* @var $databaseColumnMap array */
				$idColumn			= $item::$databaseColumnId;
				$commentIdColumn	= $commentClass::$databaseColumnId;
				$commentIds			= array_keys( Request::i()->multimod );
				$firstComment		= $commentClass::loadAndCheckPerms( array_shift( $commentIds ) );
				$contentColumn		= $commentClass::$databaseColumnMap['content'];
				$firstComment->$contentColumn = $values['final_comment_content'];
				$firstComment->save();
				
				foreach( $commentIds AS $id )
				{
					try
					{
						$comment = $commentClass::loadAndCheckPerms( $id );
						Db::i()->update( 'core_attachments_map', array(
							'id1'	=> $item->$idColumn,
							'id2'	=> $firstComment->$commentIdColumn,
						), array( 'location_key=? AND id1=? AND id2=?', $item::$application . '_' . IPS::mb_ucfirst( $item::$module ), $item->$idColumn, $comment->$commentIdColumn ) );

						/* Merge likes */
						if ( IPS::classUsesTrait( $commentClass, 'IPS\Content\Reactable' ) )
						{
							Db::i()->update( 'core_reputation_index', array( 'type_id' => $firstComment->$commentIdColumn ), array( 'app=? and type=? and type_id=?', $item::$application, $comment::reactionType(), $id ) );
						}

						$comment->delete();
					}
					catch( Exception $e ) {}
				}

				/* Fix duplicated reactions */
				if ( IPS::classUsesTrait( $commentClass, 'IPS\Content\Reactable' ) )
				{
					Db::i()->delete( array( 'row1' => 'core_reputation_index', 'row2' => 'core_reputation_index' ), 'row1.id > row2.id AND row1.member_id = row2.member_id AND row1.app = \'' . $item::$application . '\' AND row1.type = \'' . $comment::reactionType() . '\' AND row2.app = \'' . $item::$application . '\' AND row2.type = \'' . $comment::reactionType() . '\' AND row1.type_id = ' . $firstComment->$commentIdColumn . ' AND row2.type_id = ' . $firstComment->$commentIdColumn, NULL, NULL, NULL, 'row1' );
				}

				$item->rebuildFirstAndLastCommentData();

				/* Log it */
				Session::i()->modLog( 'modlog__action_merge_comments', array(
					$firstComment::$title					=> TRUE,
					$firstComment->url()->__toString()		=> FALSE,
					$firstComment->$commentIdColumn			=> FALSE,
				), $item );
				
				Output::i()->redirect( $firstComment->url() );
			}
			else
			{
				$this->_setBreadcrumbAndTitle( $item );
				Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
				
				if ( Request::i()->isAjax() )
				{
					Output::i()->sendOutput( Output::i()->output );
				}
				else
				{
					Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core' )->globalTemplate( Output::i()->title, Output::i()->output, array( 'app' => Dispatcher::i()->application->directory, 'module' => Dispatcher::i()->module->key, 'controller' => Dispatcher::i()->controller ) ) );
				}
			}
		}
		elseif ( Request::i()->modaction == 'hide' )
		{
			if ( $commentClass::$hideLogKey )
			{
				$form = new Form;
				$form->class = 'ipsForm--vertical ipsForm--hide';
				$form->add( new Text( 'hide_reason' ) );

				if ( $values = $form->values() )
				{
					foreach( array_keys( Request::i()->multimod ) AS $id )
					{
						try
						{
							$comment = $commentClass::loadAndCheckPerms( $id );
							$comment->modAction( 'hide', NULL, $values['hide_reason'] );
						}
						catch( Exception $e ) { }
					}

					if( ! in_array( 'IPS\Content\Review', class_parents( $commentClass ) ) )
					{
						$item->rebuildFirstAndLastCommentData();
					}
					
					$url = $item->url();
					
					if ( isset( Request::i()->page ) )
					{
						$url = $url->setPage( 'page', (int) Request::i()->page );
					}
					
					Output::i()->redirect( $url );
				}
				else
				{
					$this->_setBreadcrumbAndTitle( $item );
					Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );

					if ( Request::i()->isAjax() )
					{
						Output::i()->sendOutput( Output::i()->output );
					}
					else
					{
						Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core' )->globalTemplate( Output::i()->title, Output::i()->output, array( 'app' => Dispatcher::i()->application->directory, 'module' => Dispatcher::i()->module->key, 'controller' => Dispatcher::i()->controller ) ) );
					}
				}
			}
			else
			{
				foreach( array_keys( Request::i()->multimod ) AS $id )
				{
					try
					{
						$comment = $commentClass::loadAndCheckPerms( $id );
						$comment->modAction( 'hide' );
					}
					catch( Exception $e ) { }
				}
				
				$url = $item->url();
				
				if ( isset( Request::i()->page ) )
				{
					$url = $url->setPage( 'page', (int) Request::i()->page );
				}
				
				Output::i()->redirect( $url );
			}

		}
		else
		{
			$object = NULL;

			if( isset( Request::i()->multimod ) AND is_array( Request::i()->multimod ) )
			{
				foreach ( array_keys( Request::i()->multimod ) as $id )
				{
					try
					{
						$object = $commentClass::loadAndCheckPerms( $id );
						if ( IPS::classUsesTrait( $object, Featurable::class ) and Request::i()->modaction === 'delete' and $object->isFeatured() )
						{
							$item->unfeatureComment( $object );
						}
						$object->modAction( Request::i()->modaction, Member::loggedIn() );
					}
					catch ( Exception $e ) {}
				}
			}

			$item->resyncCommentCounts();
			$item->save();
						
			if ( $object and Request::i()->modaction != 'delete' )
			{
				$url = $object->url();
				
				if ( isset( Request::i()->page ) )
				{
					$url = $url->setQueryString( 'page', (int) Request::i()->page );
				}
				
				Output::i()->redirect( $url );
			}
			else
			{
				$url = $item->url();
				
				if ( isset( Request::i()->page ) )
				{
					$url = $url->setPage( 'page', (int) Request::i()->page );
				}
				
				Output::i()->redirect( $url );
			}
		}
	}

	/**
	 * Form for splitting
	 *
	 * @param Item $item The item
	 * @param Comment|null $comment
	 * @return    Form
	 */
	protected function _splitForm(Item $item, Comment|null $comment = NULL ) : Form
	{
		try
		{
			$container = $item->container();
		}
		catch ( Exception $e )
		{
			$container = NULL;	
		}
		
		$form = new Form;
		if ( $item::canCreate(Member::loggedIn()) )
		{
			$toAdd = array();
			$toggles = array();
							
			foreach ($item::formElements($item) as $k => $field )
			{				
				if ( !in_array( $k, array( 'poll', 'content', 'comment_edit_reason', 'comment_log_edit' ) ) )
				{
					if ( $k === 'container' AND ( $container AND $container->can( 'add' ) ) )
					{
						$field->defaultValue = $container;
						if ( !$field->value )
						{
							$field->value = $field->defaultValue;
						}
					}
					
					if ( !$field->htmlId )
					{
						$field->htmlId = $field->name;
					}
					$toggles[] = $field->htmlId;
					
					$toAdd[] = $field;
				}
			}
			
			$form->add( new Radio( '_split_type', 'new', FALSE, array(
				'options' => array(
					'new'		=> Member::loggedIn()->language()->addToStack( 'split_type_new', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( $item::$title ) ) ) ),
					'existing'	=> Member::loggedIn()->language()->addToStack( 'split_type_existing', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( $item::$title ) ) ) )
				),
				'toggles' => array( 'new' => $toggles, 'existing' => array( 'split_into_url' ) ),
			) ) );

			foreach ( $toAdd as $field )
			{
				if ( $field->name == $item::$formLangPrefix . 'container' )
				{
					/* Add a custom permission check for splitting comments */
					$field->options['permissionCheck'] = function( $node ) use ( $item )
					{
						try
						{
							/* If the item is in a club, only allow moving to other clubs that you moderate */
							if ( Settings::i()->clubs and IPS::classUsesTrait( $item->container(), 'IPS\Content\ClubContainer' ) and $item->container()->club()  )
							{
								return $item::modPermission( 'move', Member::loggedIn(), $node ) and $node->can( 'add' ) ;
							}

							if ( $node->can( 'add' ) )
							{
								return true;
							}
						}
						catch( OutOfBoundsException $e ) { }

						return false;
					};
					if ( Settings::i()->clubs and IPS::classUsesTrait( $item->container(), 'IPS\Content\ClubContainer' ) )
					{
						$field->options['clubs'] = TRUE;
					}
				}

				$form->add( $field );
			}
		}
		$form->add( new FormUrl( '_split_into_url', NULL, FALSE, array(), function ( $val ) use ( $item )
		{
			if ( Request::i()->_split_type == 'existing' OR !isset( Request::i()->_split_type ) )
			{
				try
				{
					/* Validate the URL */
					$test = $item::loadFromUrl( $val );

					if ( !$test->canView() )
					{
						throw new InvalidArgumentException;
					}

					/* Make sure the URL matches the content type we're splitting */
					foreach( array( 'app', 'module', 'controller') as $index )
					{
						if( $test->url()->hiddenQueryString[ $index ] != $val->hiddenQueryString[ $index ] )
						{
							throw new InvalidArgumentException;
						}
					}
				}
				catch ( Exception $e )
				{
					throw new DomainException( Member::loggedIn()->language()->addToStack( 'form_url_bad_item', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( '__defart_' . $item::$title ) ) ) ) );
				}

				if( !$test->canMerge() )
				{
					throw new DomainException( Member::loggedIn()->language()->addToStack( 'no_merge_permission', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack( '__defart_' . $item::$title ) ) ) ) );
				}
			}
		}, NULL, NULL, 'split_into_url' ) );

		/* multi mod doesn't support moderation alerts , so if $comment isn't set, we can't use it */
		if( $comment )
		{
			$this->moderationAlertField($form, $comment );
		}

		return $form;
	}

	/**
	 * Retrieve content tagged the same
	 *
	 * @param int $limit	How many items should be returned
	 *
	 * @note	Used with a widget, but can be used elsewhere too
	 * @return	array|NULL
	 */
	public function getSimilarContent( int $limit = 5 ): array|NULL
	{
		if( !isset( static::$contentModel ) )
		{
			return NULL;
		}

		$class = static::$contentModel;
		if( !is_subclass_of( $class, 'IPS\Content\Item' ) )
		{
			return NULL;
		}

		try
		{
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$items = [];

			/* Are we using elasticsearch? */
			if ( Settings::i()->search_method == 'elastic' )
			{
				$query = Query::init( Member::loggedIn() );
				$query->resultsToGet = $limit;
				$query->filterByMoreLikeThis( $item );
				$query->filterByLastUpdatedDate( DateTime::create()->sub( new DateInterval( 'P2Y' ) ) );
				$results = $query->search();
				$results->init();

				foreach( $results as $result )
				{
					$object = $result->asArray()['indexData'];
					$class = $object['index_class'];
					$loaded = $class::loadAndCheckPerms( $object['index_object_id'] );

					if ( $loaded instanceof Item)
					{
						$obj = $loaded;
					}
					else
					{
						$obj = $loaded->item();
					}

					$items[] = $obj;
				}
			}
			else
			{
				if ( !IPS::classUsesTrait( $item, 'IPS\Content\Taggable' ) or ( $item->tags() === NULL and $item->prefix() === NULL ) )
				{
					return NULL;
				}

				/* Store tags in array, so that we can add a prefix if set */
				$tags = $item->tags() ?: array();
				$tags[] = $item->prefix();

				/* Build the where clause */
				$where = array(
					array('(' . Db::i()->in( 'tag_text', $tags ) . ')'),
					array('!(tag_meta_app=? and tag_meta_area=? and tag_meta_id=?)', $class::$application, $class::$module, Request::i()->id),
					array('(' . Db::i()->findInSet( 'tag_perm_text', Member::loggedIn()->groups ) . ' OR ' . 'tag_perm_text=? )', '*'),
					array('tag_perm_visible=1')
				);

				/* Allow the item to manipulate the query if needed */
				if ( $item->similarContentFilter() )
				{
					$where = array_merge( $where, $item->similarContentFilter() );
				}

				$select = Db::i()->select(
					'tag_meta_app,tag_meta_area,tag_meta_id',
					'core_tags',
					$where,
					'tag_added DESC',
					array(0, $limit),
					array('tag_meta_app', 'tag_meta_area', 'tag_meta_id', 'tag_added')
				)->join(
					'core_tags_perms',
					array('tag_perm_aai_lookup=tag_aai_lookup')
				);

				foreach ( $select as $result )
				{
					foreach ( Application::load( $result['tag_meta_app'] )->extensions( 'core', 'ContentRouter' ) as $key => $router )
					{
						foreach ( $router->classes as $itemClass )
						{
							if ( $itemClass::$module == $result['tag_meta_area'] )
							{
								try
								{
									$items[$result['tag_meta_id']] = $itemClass::loadAndCheckPerms( $result['tag_meta_id'] );
									break;
								}
								catch ( Exception $e )
								{
								}
							}
						}
					}

				}
			}

			return $items;
		}
		catch ( Exception $e )
		{
			return NULL;
		}
	}
	
	/**
	 * Get Cover Photo Storage Extension
	 *
	 * @return	string
	 */
	protected function _coverPhotoStorageExtension(): string
	{
		$class = static::$contentModel;
		return $class::$coverPhotoStorageExtension;
	}
	
	/**
	 * Set Cover Photo
	 *
	 * @param	CoverPhoto	$photo	New Photo
	 * @return	void
	 */
	protected function _coverPhotoSet( CoverPhoto $photo ): void
	{
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			/* @var $databaseColumnMap array */
			$photoColumn = $class::$databaseColumnMap['cover_photo'];
			$item->$photoColumn = (string) $photo->file;
			
			$offsetColumn = $class::$databaseColumnMap['cover_photo_offset'];
			$item->$offsetColumn = $photo->offset;
			
			$item->save();
		}
		catch ( OutOfRangeException $e ){}
	}

	/**
	 * Get Cover Photo
	 *
	 * @return	CoverPhoto
	 */
	protected function _coverPhotoGet(): CoverPhoto
	{
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			
			return $item->coverPhoto();
		}
		catch ( OutOfRangeException $e )
		{
			return new CoverPhoto;
		}
	}
	
	/**
	 * Reveal
	 *
	 * @return	void
	 */
	protected function reveal(): void
	{
		Session::i()->csrfCheck();
		
		if( !Member::loggedIn()->modPermission( 'can_view_anonymous_posters' ) )
		{
			Output::i()->error( 'node_error', '2S136/1V', 403, '' );
		}
		
		$class = static::$contentModel;
		$item = $class::loadAndCheckPerms( Request::i()->id );
		
		if( isset( Request::i()->comment ) )
		{
			$class = $class::$commentClass;
			$id = Request::i()->comment;
		}
		else
		{
			$id = Request::i()->id;
		}
		
		try
		{
			$anonymousAuthor = Db::i()->select( 'anonymous_member_id', 'core_anonymous_posts', array( 'anonymous_object_class=? and anonymous_object_id=?', $class, $id )  )->first();
		}
		catch ( UnderflowException $e )
		{
			if( Request::i()->isAjax() )
			{
				Output::i()->output = Theme::i()->getTemplate( 'global', 'core', 'global' )->basicHover( Member::loggedIn()->language()->addToStack( 'anon_user_deleted' ) );
				return;
			}
			Output::i()->error( 'anon_user_deleted', '2S136/1W', 403, '' );
		}
		
		$author = Member::load( $anonymousAuthor );

		$addWarningUrl = Url::internal( "app=core&module=system&controller=warnings&do=warn&id={$author->member_id}", 'front', 'warn_add', array( $author->members_seo_name ) );
		if ( isset( Request::i()->wr ) )
		{
			$addWarningUrl = $addWarningUrl->setQueryString( 'ref', Request::i()->wr );
		}
		
		if( Request::i()->isAjax() )
		{
			Output::i()->output = Theme::i()->getTemplate( 'profile', 'core' )->hovercard( $author, $addWarningUrl );
		}
		else
		{
			Output::i()->redirect( $author->url() );
		}
	}
	
	/**
	 * Reveal Comment
	 *
	 * @return	void
	 */
	protected function revealComment(): void
	{
		$this->reveal();
	}

	/**
	 * A convenient hook point to finish any set up in manage()
	 *
	 * @param Item $item	The item that is being set up in manage()
	 * @return	void
	 */
	protected function finishManage( Item $item ): void
	{
	}

	/**
	 * Get the moderation alert fields
	 *
	 * @param Form $form
	 * @param Content $item
	 * @return void
	 */
	protected function moderationAlertField( Form $form, Content $item ): void
	{
		if( Member::loggedIn()->modPermission('can_manage_alerts') AND $item->author()->member_id )
		{
			$form->add( new YesNo( 'moderation_alert', FALSE, FALSE, array( 'togglesOn' => array( 'moderation_alert_title', 'moderation_alert_content','moderation_alert_anonymous','moderation_alert_reply' ) ) ) );
			Member::loggedIn()->language()->words[ 'moderation_alert'] = Member::loggedIn()->language()->addToStack('moderation_alert_name', FALSE, ['sprintf' => [ $item->author()->name ] ] );
			$form->add( new Text( 'moderation_alert_title', NULL, TRUE, array( 'maxLength' => 255 ), NULL, NULL, NULL, 'moderation_alert_title' ) );
			$form->add( new Editor( 'moderation_alert_content', NULL, TRUE, array( 'app' => 'core', 'key' => 'Alert', 'autoSaveKey' => 'createAlert', 'attachIds' => NULL ), NULL, NULL, NULL, 'moderation_alert_content' ) );
			$form->add( new YesNo( 'moderation_alert_anonymous', FALSE, TRUE, array( 'togglesOff' => array( 'moderation_alert_reply') ), NULL, NULL, NULL, 'moderation_alert_anonymous' ) );
			$form->add( new Radio( 'moderation_alert_reply', 0, TRUE, array( 'disabled' => (bool) Member::loggedIn()->members_disable_pm, 'options' => array( '0' => 'alert_no_reply', '1' => 'alert_can_reply', '2' => 'alert_must_reply' ) ), NULL, NULL, NULL, 'moderation_alert_reply' ) );

			if ( Member::loggedIn()->members_disable_pm )
			{
				Member::loggedIn()->language()->words['alert_reply_desc'] = Member::loggedIn()->language()->get('alert_reply__nopm_desc');
			}
		}
	}

	/**
	 * Send the alert
	 *
	 * @param array $values
	 * @param Item|Comment $content
	 * @return Alert
	 */
	protected function sendModerationAlert(array $values, Item|Comment $content ) : Alert
	{
		$values['alert_title'] = $values['moderation_alert_title'];
		$values['alert_content'] = $values['moderation_alert_content'];
		$values['alert_recipient_type'] = 'user';
		$values['alert_recipient_user'] = $content->author()->member_id;
		$values['alert_anonymous'] = $values['moderation_alert_anonymous'];
		$values['alert_reply'] = $values['moderation_alert_reply'];
		return Alert::_createFromForm( $values, NULL );
	}

	/**
	 * Show Helpful
	 *
	 * @return    void
	 * @throws Exception
	 */
	protected function showHelpful(): void
	{
		try
		{
			$class = static::$contentModel;
			$commentClass = $class::$commentClass;
			$idColumn = $commentClass::$databaseColumnId;

			if( !IPS::classUsesTrait( $commentClass, 'IPS\Content\Helpful' ) or !Member::loggedIn()->group['gbw_view_helpful'] )
			{
				Output::i()->error( 'node_error', '2S136/29', 404, 'node_error' );
			}

			$comment = $commentClass::loadAndCheckPerms( Request::i()->answer );
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2S136/2A', 404, 'node_error' );
		}

		$table = new TableDb( 'core_solved_index', $comment->url()->setQueryString( array( 'do' => 'showHelpful' ) ), array( 'comment_id=?', $comment->$idColumn ) );
		$table->tableTemplate = array( Theme::i()->getTemplate( 'global', 'core', 'front' ), 'helpfulLogTable' );
		$table->rowsTemplate = array( Theme::i()->getTemplate( 'global', 'core', 'front' ), 'helpfulLog' );

		Output::i()->metaTags['robots'] = 'noindex';
		Output::i()->output = $table;
	}

	/**
	 * Feature promotion dialog
	 *
	 * @return    void
	 * @throws ErrorException
	 */
	protected function feature(): void
	{
		try
		{
			$class = static::$contentModel;
			$idColumn = $class::$databaseColumnId;
			$item = $class::loadAndCheckPerms( Request::i()->id );
		}
		catch( Exception )
		{
			Output::i()->error( 'page_not_found', '2C356/6', 404, '' );
		}

		if ( isset( Request::i()->comment ) )
		{
			try
			{
				$commentClass = $item::$commentClass;
				$comment = $commentClass::loadAndCheckPerms( Request::i()->comment );

				/* Is this the first comment in an item? */
				if ( $item::$firstCommentRequired and $comment->isFirst() )
				{
					/* Yeah, so just promote the item */
					$objectToPromote = $item;
				}
				else
				{
					$idColumn = $commentClass::$databaseColumnId;
					$objectToPromote = $comment;
				}
			}
			catch( Exception )
			{
				Output::i()->error( 'page_not_found', '2C356/H', 404, '' );
			}
		}
		else
		{
			$objectToPromote = $item;
		}

		/* Check that the object can be promoted */
		if( ! $objectToPromote->canFeature() )
		{
			Output::i()->error( 'no_promote_permission', '2S356/A', 403, '' );
		}

		$form = new Form( 'form', 'promote_submit' );
		$form->class = 'cPromoteDialog';

		$form->add( new YesNo( 'promote_toggle_custom', false, false, array(
			'togglesOn' => array( 'promote_title', 'promote_content' )
		) ) );

		$form->add( new Text( 'promote_social_title_internal', Feature::objectTitle( $objectToPromote, true ), FALSE, [], NULL, NULL, NULL, 'promote_title' ) );
		$form->add( new TextArea( 'promote_social_content_internal', Feature::objectContent( $objectToPromote ) ?: NULL, FALSE, array( 'maxLength' => 3000, 'rows' => 6 ), NULL, NULL, NULL, 'promote_content'  ) );

		/* Existing media */
		try
		{
			if ( $images = $objectToPromote->contentImages( 20 ) )
			{
				$form->addHtml( Theme::i()->getTemplate( 'promote', 'core', 'front' )->promoteDialogImages( $images ) );
			}
		}
		catch( BadMethodCallException $e ) { }

		/* Upload box */
		$uploader = new Upload( 'promote_media', NULL, FALSE, array(
			'multiple' => TRUE,
			'storageExtension' => 'core_Promote',
			'maxFiles' => 10,
			'image' => TRUE,
			'maxFileSize' => 3,
			'template' => "promote.imageUpload",
		) );

		$uploader->template = array( Theme::i()->getTemplate( 'promote', 'core', 'front' ), 'promoteAttachments' );
		$form->add( $uploader );

		/* Should we alert the member to say thanks? */
		$this->moderationAlertField( $form, $objectToPromote );

		/* Are we featuring a comment? We still use the metadata:featuredComments system for this as it's the most efficient */
		if ( $objectToPromote instanceof Comment and in_array( 'core_FeaturedComments', $item::supportedMetaDataTypes() ) )
		{
			$form->add( new YesNo( 'promote_toggle_comment', false, false, array(
				'togglesOn' => array( 'feature_mod_note' )
			) ) );

			$form->add( new Text( 'feature_mod_note', NULL, FALSE, [], NULL, NULL, NULL, 'feature_mod_note' ) );
		}

		/* Saving? */
		if ( $values = $form->values() )
		{
			$media = array();
			if ( is_array( $values['promote_media'] ) )
			{
				foreach( $values['promote_media'] as $key => $file )
				{
					$media[] = (string) $file;
				}
			}

			/* Content images */
			$saveImages = array();
			if ( isset( $images ) AND $images )
			{
				foreach( $images as $image )
				{
					foreach( $image as $extension => $path )
					{
						if ( Request::i()->attach_files and in_array( $path, array_keys( Request::i()->attach_files ) ) )
						{
							$saveImages[] = array( $extension => $path );
						}
					}
				}
			}

			$promote = new Feature();
			$promote->class = get_class( $objectToPromote );
			$promote->class_id = $objectToPromote->$idColumn;
			$promote->added_by = Member::loggedIn()->member_id;
			$promote->text = $values['promote_toggle_custom'] ? [ 'internal' => $values['promote_social_content_internal'] ] : '';
			$promote->images = $saveImages;
			$promote->media = $media;
			$promote->added = time();
			$promote->form_data = array (
				'internal' => $values['promote_toggle_custom'] ? array( 'title' => $values['promote_social_title_internal'] ) : null
			);

			$promote->author_id = $item->author()->member_id ?: 0;
			$promote->save();

			/* Now set the featured item */
			$objectToPromote->setFeatured( true );

			/* Is a comment, and we're featuring it at the top? */
			if ( $objectToPromote instanceof Comment and $values['promote_toggle_comment'] )
			{
				Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->featureComment( $objectToPromote->item(), $objectToPromote, $values['feature_mod_note'], Member::loggedIn() );

				Session::i()->modLog( 'modlog__featured_comment', array(
					(string) $objectToPromote->url()	=> FALSE,
					$item->mapped('title')		=> FALSE
				) );
			}

			/* Send an alert if required */
			if( isset( $values['moderation_alert_content'] ) AND $values['moderation_alert_content'] )
			{
				$this->sendModerationAlert( $values, $objectToPromote );
			}

			/* Points */
			$objectToPromote->author()->achievementAction( 'core', 'ContentPromotion', [
				'content' => $objectToPromote,
				'promotype' => 'promote'
			] );

			Webhook::fire( 'content_promoted', $promote );

			Output::i()->redirect( $objectToPromote->url(), Member::loggedIn()->language()->addToStack( 'promote_complete' ) );
		}
		else
		{
			$output = Theme::i()->getTemplate( 'promote', 'core' )->promoteDialog( Member::loggedIn()->language()->addToStack('promote_social_title'), $form, $objectToPromote );
		}

		Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js('front_promote.js', 'core' ) );
		Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'styles/promote.css' ) );

		Output::i()->title = Member::loggedIn()->language()->addToStack('promote_social_button');
		Output::i()->output = $output;
	}

	/**
	 * Deletes a featured item
	 *
	 * @return void
	 * @throws Exception
	 */
	protected function unfeature() : void
	{
		Session::i()->csrfCheck();

		try
		{
			$class = static::$contentModel;
			$idColumn = $class::$databaseColumnId;
			$item = $class::loadAndCheckPerms( Request::i()->id );
		}
		catch( Exception )
		{
			Output::i()->error( 'page_not_found', '2C356/6', 404, '' );
		}

		if ( isset( Request::i()->comment ) )
		{
			try
			{
				$commentClass = $item::$commentClass;
				$comment = $commentClass::loadAndCheckPerms( Request::i()->comment );
				$idColumn = $commentClass::$databaseColumnId;
				$objectToPromote = $comment;
			}
			catch( Exception )
			{
				Output::i()->error( 'page_not_found', '2C356/I', 404, '' );
			}
		}
		else
		{
			$objectToPromote = $item;
		}

		/* Check that the object can be promoted */
		/* @var Content $objectToPromote */
		if( ! $objectToPromote->canFeature() and !$objectToPromote->isFeatured() )
		{
			Output::i()->error( 'no_promote_permission', '2S356/G', 403, '' );
		}

		try
		{
			$promote = Feature::loadByClassAndId( get_class( $objectToPromote ), $objectToPromote->$idColumn );

			/* Now set the featured item */
			$objectToPromote->setFeatured( false );

			/* .. and delete */
			$promote?->delete();

			/* Is a comment, and we're featuring it at the top? */
			if ( $objectToPromote instanceof Comment )
			{
				if ( Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->isCommentShownAtTheTop( $objectToPromote ) )
				{
					Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->unfeatureComment( $objectToPromote->item(), $objectToPromote, Member::loggedIn() );

					Session::i()->modLog( 'modlog__unfeatured_comment', array(
						(string) $objectToPromote->url()	=> FALSE,
						$item->mapped('title')		=> FALSE
					) );
				}
			}

			if( Request::i()->fromItem )
			{
				Output::i()->redirect( $item->url(), 'promote_item_deleted' );
			}
			else
			{
				Output::i()->redirect( Url::internal( 'app=core&module=modcp&controller=modcp&tab=featured', 'front', 'modcp_featured' ), 'promote_item_deleted' );
			}
		}
		catch( OutOfRangeException $ex )
		{
			Output::i()->error( 'page_not_found', '2C356/3', 404, '' );
		}
	}

	/**
	 * Edit featured
	 *
	 * @return void
	 * @throws Exception
	 */
	protected function editFeatured() : void
	{
		try
		{
			$class = static::$contentModel;
			$item = $class::loadAndCheckPerms( Request::i()->id );
			$idColumn = $item::$databaseColumnId;
		}
		catch( Exception )
		{
			Output::i()->error( 'page_not_found', '2C356/5', 404, '' );
		}

		if ( isset( Request::i()->comment ) )
		{
			try
			{
				/* @var $item Content */
				$commentClass = $item::$commentClass;
				$comment = $commentClass::loadAndCheckPerms( Request::i()->comment );
				$class = $commentClass;

				/* Is this the first comment in an item? */
				if ( $item::$firstCommentRequired and $comment->isFirst() )
				{
					/* Yeah, so just promote the item */
					$objectToEdit = $item;
				}
				else
				{
					$idColumn = $commentClass::$databaseColumnId;
					$objectToEdit = $comment;
				}
			}
			catch( Exception )
			{
				Output::i()->error( 'page_not_found', '2C356/H', 404, '' );
			}
		}
		else
		{
			$objectToEdit = $item;
		}

		try
		{
			$promote = Feature::loadByClassAndId( $class, $objectToEdit->$idColumn );
		}
		catch( OutOfRangeException )
		{
			Output::i()->error( 'page_not_found', '2C356/5', 404, '' );
		}

		if ( ! $promote or !$objectToEdit->canFeature() )
		{
			Output::i()->error( 'no_module_permission', '2C366/2', 403, '' );
		}

		$title = NULL;
		$settings = $promote->form_data;

		if ( ! empty( $settings['internal']['title'] ) )
		{
			$title = $settings['internal']['title'];
		}

		$form = new Form( 'form', 'save' );
		$form->class = 'ipsForm--vertical cPromoteDialog ipsForm--edit-featured';
		$form->add( new Text( 'promote_social_title_internal', $title, FALSE ) );
		$form->add( new TextArea( 'promote_internal_text', $promote->getText(), FALSE, array( 'rows' => 10 ) ) );

		/* Existing media */
		try
		{
			if ( $images = $item->contentImages( 20 ) )
			{
				$form->addHtml( Theme::i()->getTemplate( 'promote', 'core' )->promoteDialogImages( $images, $promote ) );
			}
		}
		catch( BadMethodCallException ) { }

		/* Upload box */
		$existingMedia = array();
		if ( $promote )
		{
			foreach( $promote->media as $file )
			{
				try
				{
					$existingMedia[] = File::get( 'core_Promote', $file );
				}
				catch ( OutOfRangeException $e ) { }
			}
		}

		$uploader = new Upload( 'promote_media', $existingMedia, FALSE, array(
			'multiple' => TRUE,
			'storageExtension' => 'core_Promote',
			'maxFiles' => 10,
			'image' => TRUE,
			'maxFileSize' => 3,
		) );

		$form->add( $uploader );

		/* Saving? */
		if ( $values = $form->values() )
		{
			/* Content images */
			$saveImages = array();
			if ( $images )
			{
				foreach( $images as $image )
				{
					foreach( $image as $extension => $path )
					{
						if ( Request::i()->attach_files and in_array( $path, array_keys( Request::i()->attach_files ) ) )
						{
							$saveImages[] = array( $extension => $path );
						}
					}
				}
			}

			$media = array();
			if ( is_array( $values['promote_media'] ) )
			{
				foreach( $values['promote_media'] as $key => $file )
				{
					$media[] = (string) $file;
				}
			}

			$settings['internal']['title'] = $values['promote_social_title_internal'];

			$promote->images = $saveImages;
			$promote->media = $media;
			$promote->form_data = $settings;
			$promote->setText( $values['promote_internal_text'] );
			$promote->save();

			Webhook::fire( 'content_promoted', $promote );

			/* Redirect back to the list */
			Output::i()->redirect( Url::internal( 'app=core&module=modcp&controller=modcp&tab=featured', 'front', 'modcp_featured' ), 'saved' );
		}

		Output::i()->title = Member::loggedIn()->language()->addToStack('promote_internal_edit');
		Output::i()->output = Theme::i()->getTemplate( 'promote', 'core' )->edit( $form );
	}

	/**
	 * Mark as spam / unmark as spam controllers (but not currently used)
	 */
	protected function spam() : void
	{
		try
		{
			$itemClass = static::$contentModel;
			$item = $itemClass::load( Request::i()->id );
			Bridge::i()->markItemAsSpam( $item );
		}
		catch( OutOfRangeException ){}
	}

	protected function unspam() : void
	{
		try
		{
			$itemClass = static::$contentModel;
			$item = $itemClass::load( Request::i()->id );
			Bridge::i()->markItemAsNotSpam( $item );
		}
		catch( OutOfRangeException ){}
	}

	protected function _spam( $commentClass, $comment, $item ) : void
	{
		Bridge::i()->markCommentAsSpam( $commentClass, $comment, $item );
	}

	protected function _unspam( $commentClass, $comment, $item ) : void
	{
		Bridge::i()->markCommentAsNotSpam( $commentClass, $comment, $item );
	}

	/**
	 * Run a workflow
	 *
	 * @return void
	 */
	protected function workflow() : void
	{
		if( Bridge::i()->featureIsEnabled( 'workflow' ) )
		{
			try
			{
				$workflow = Workflow::load( Request::i()->workflow );
				if( !$workflow->canRun() )
				{
					throw new OutOfRangeException;
				}
			}
			catch( OutOfRangeException )
			{
				Output::i()->error( 'node_error', '2S136/2C', 404 );
			}

			/* Make sure that the workflow is valid for this item */
			try
			{
				$itemClass = static::$contentModel;
				$item = $itemClass::load( Request::i()->id );
				if( !array_key_exists( $workflow->id, Workflow::loadForItem( $item ) ) )
				{
					throw new OutOfRangeException;
				}
			}
			catch( OutOfRangeException )
			{
				Output::i()->error( 'node_error', '2S136/2D', 404 );
			}

			/* Are we showing a form? */
			if( $form = $workflow->run( $item ) )
			{
				Output::i()->output = (string) $form;
			}
			else
			{
				Output::i()->redirect( $item->url() );
			}
		}
	}
}