<?php
/**
 * @brief		Topic View
 * @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
 * @subpackage	Forums
 * @since		08 Jan 2014
 */

namespace IPS\forums\modules\front\forums;

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

use BadMethodCallException;
use DateInterval;
use DateTimeInterface;
use Exception;
use IPS\Application;
use IPS\Content\Comment;
use IPS\Content\Controller;
use IPS\Content\Item;
use IPS\Content\Reaction;
use IPS\DateTime;
use IPS\Db;
use IPS\File;
use IPS\forums\Forum;
use IPS\forums\SavedAction;
use IPS\forums\Topic as TopicClass;
use IPS\forums\Topic\Post;
use IPS\Helpers\Form;
use IPS\IPS;
use IPS\Log;
use IPS\Member;
use IPS\Output;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
use IPS\Theme;
use LogicException;
use OutOfRangeException;
use Throwable;
use function array_merge;
use function count;
use function defined;
use function in_array;
use function intval;
use function is_numeric;
use const IPS\LARGE_TOPIC_REPLIES;

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

/**
 * Topic View
 */
class topic extends Controller
{
	/**
	 * [Content\Controller]	Class
	 */
	protected static string $contentModel = 'IPS\forums\Topic';
	
	/**
	 * Execute
	 *
	 * @return	void
	 */
	public function execute() : void
	{
		Output::i()->jsFiles	= array_merge( Output::i()->jsFiles, Output::i()->js('front_topic.js', 'forums' ), Output::i()->js('front_helpful.js', 'core' ) );

        if ( Application::appIsEnabled('cloud') )
        {
            Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'front_realtime.js', 'cloud', 'front' ) );
        }
        
		parent::execute();
	}

	/**
	 * View Topic
	 *
	 * @return    mixed
	 * @throws Exception
	 */
	protected function manage() : mixed
	{
		/* Load topic */
		$topic = parent::manage();
		Member::loggedIn()->language()->words['submit_comment'] = Member::loggedIn()->language()->addToStack( 'submit_reply', FALSE );

		/* If it failed, it might be because we want a password */
		if ( $topic === NULL )
		{
			$forum = NULL;
			try
			{
				$topic = TopicClass::load( Request::i()->id );
				$forum = $topic->container();
				if ( $forum->can('view') and !$forum->loggedInMemberHasPasswordAccess() )
				{
					Output::i()->redirect( $forum->url()->setQueryString( 'topic', Request::i()->id ) );
				}
				
				if ( !$topic->canView() )
				{
					if ( IPS::classUsesTrait( $topic, 'IPS\Content\Hideable' ) and $topic->hidden() )
					{
						/* If the item is hidden we don't want to show the custom no permission error as the conditions may not apply */
						Output::i()->error( 'node_error', '2F173/O', 404, '' );
					}
					else
					{
						Output::i()->error(  $forum ? $forum->errorMessage() : 'node_error_no_perm', '2F173/H', 403, '' );
					}
				}
			}
			catch ( OutOfRangeException $e )
			{
				/* Nope, just a generic no access error */
				Output::i()->error( 'node_error', '2F173/1', 404, '' );
			}
		}
		
		$topic->container()->clubCheckRules();
		
		/* If there's only one forum and we're not in a club, and we're not in a sub-forum, we actually don't want the nav */
		$theOnlyForum = NULL;
		if ( count( Output::i()->breadcrumb ) AND !$topic->container()->club() AND ( ( $theOnlyForum = Forum::theOnlyForum() AND $theOnlyForum->_id == $topic->container()->_id ) or Forum::isSimpleView() ) )
		{
			$topicBreadcrumb = array_pop( Output::i()->breadcrumb );
			Output::i()->breadcrumb = isset( Output::i()->breadcrumb['module'] ) ? array( 'module' => Output::i()->breadcrumb['module'] ) : array();
			Output::i()->breadcrumb[] = $topicBreadcrumb;
		}
		
		/* We need to shift the breadcrumb if we are in a sub-forum and we have $theOnlyForum */
		if ( $theOnlyForum AND $theOnlyForum->_id != $topic->container()->_id )
		{
			array_shift( Output::i()->breadcrumb );
			array_shift( Output::i()->breadcrumb );
		}

		/* Legacy findpost redirect */
		if ( Request::i()->findpost )
		{
			Output::i()->redirect( $topic->url()->setQueryString( array( 'do' => 'findComment', 'comment' => Request::i()->findpost ) ), NULL, 301 );
		}
		elseif ( Request::i()->p )
		{
			Output::i()->redirect( $topic->url()->setQueryString( array( 'do' => 'findComment', 'comment' => Request::i()->p ) ), NULL, 301 );
		}
		elseif ( Request::i()->pid )
		{
			Output::i()->redirect( $topic->url()->setQueryString( array( 'do' => 'findComment', 'comment' => Request::i()->pid ) ), NULL, 301 );
		}
		
		if ( Request::i()->view )
		{
			$this->_doViewCheck();
		}

		/* If the topic is locked and scheduled to unlock already, or vice versa, do that */
		if( $topic->locked() && $topic->topic_open_time && $topic->topic_open_time < time() )
		{
			$topic->state = 'open';
			$topic->save();
		}
		elseif( !$topic->locked() && $topic->topic_close_time && $topic->topic_close_time < time() )
		{
			$topic->state = 'closed';
			$topic->save();
		}

		/* If this is an AJAX request fetch the comment form now. The HTML will be cached so calling here and then again in the template has no overhead
			and this is necessary if you entered into a topic with &queued_posts=1, approve the posts, then try to reply. Otherwise, clicking into the
			editor produces an error when the getUploader=1 call occurs, and submitting a reply results in an error. */
		if ( Request::i()->isAjax() and ( !isset( Request::i()->preview ) OR !Request::i()->preview ) )
		{
			$topic->commentForm();
		}
	
		/* AJAX hover preview? */
		if ( Request::i()->isAjax() and Request::i()->preview )
		{
			$postClass = '\IPS\forums\Topic\Post';

			if( $topic->isArchived() )
			{
				$postClass = '\IPS\forums\Topic\ArchivedPost';
			}
			
			/* If this topic was moved or merged, load that up in case someone loads the preview after that happens but before they reload the page */
			$previewTopic = $topic;
			if ( in_array( $topic->state, array( 'merged', 'link' ) ) )
			{
				$movedTo = explode( '&', $topic->moved_to );
				
				try
				{
					$previewTopic = TopicClass::loadAndCheckPerms( $movedTo[0] );
				}
				catch( OutOfRangeException $e )
				{
					/* I can't help you */
					Output::i()->error( 'node_error', '2F173/Q', 404, '' );
				}
			}

			$firstPost = $postClass::load( $previewTopic->topic_firstpost );
			
			$topicOverview = array( 'firstPost' => array( 'first_post', $firstPost ) );

			if ( $previewTopic->posts > 1 )
			{
				$latestPost = $previewTopic->comments( 1, 0, 'date', 'DESC' );
				$topicOverview['latestPost'] = array('latest_post', $latestPost );
			
				$timeLastRead = $previewTopic->timeLastRead();
				if ( $timeLastRead instanceof DateTime AND $previewTopic->unread() !== 0 )
				{
					$firstUnread = $previewTopic->comments( 1, NULL, 'date', 'asc', NULL, NULL, $timeLastRead );
					if( $firstUnread instanceof Post AND $firstUnread->date !== $latestPost->date AND $firstUnread->date !== $firstPost->date )
					{
						$topicOverview['firstUnread'] = [ 'first_unread_post_hover', $firstUnread ];
					}
				}			
			}

			if ( $previewTopic->topic_answered_pid )
			{
				$topicOverview['bestAnswer'] = array( 'best_answer_post', Post::load( $previewTopic->topic_answered_pid ) );
			}

			Output::i()->sendOutput( Theme::i()->getTemplate( 'forums' )->topicHover( $previewTopic, $topicOverview ) );
		}
		
		$topic->container()->setTheme();
		
		/* Watch for votes */
		if ( $poll = $topic->getPoll() )
		{
			$poll->attach( $topic );
		}
				
		/* How are we sorting posts? */
		$offset = NULL;
		$order = 'date';
		$orderDirection = 'asc';
		$where = NULL;
		$firstPost = null;
		$paginationKeys = array( 'sortby' );

		if ( $topic->hasSummary() AND Request::i()->topicSummary )
		{
			$pagesCount = 1;
		}
		else if( Request::i()->show == 'helpful' )
		{
			$where = [ '( topic_id=? AND ( new_topic=? OR pid IN(?) ) )', $topic->tid , 1, Db::i()->select( 'comment_id', 'core_solved_index', [ 'app=? and item_id=? and type=?', 'forums', $topic->tid, 'helpful' ] ) ];

			$pagesCount = ceil( Db::i()->select( 'COUNT(*)', 'forums_posts', $where )->first() / $topic->getCommentsPerPage() );
			$paginationKeys = array( 'queued_posts', 'sortby' );
		}
		else
		{
			$customPaginationWhere = array();
			if( TopicClass::modPermission( 'unhide', NULL, $topic->container() ) AND Request::i()->queued_posts )
			{
				if ( $topic->isArchived() )
				{
					$where = 'archive_queued=1';
				}
				else
				{
					$where = 'queued=1';
				}
				array_unshift( $paginationKeys, 'queued_posts' );

				$customPaginationWhere[] = array( 'topic_id=? AND queued=1', $topic->tid );
			}
			$customPaginationWhere = Bridge::i()->modifyTopicCommentFilter( $topic, $customPaginationWhere );
			if ( !empty( $customPaginationWhere ) )
			{
				$customPaginationWhere[] = array( "topic_id=?", $topic->tid );
				$authorCol = Post::$databasePrefix . Post::$databaseColumnMap['author'];
				if ( $topic->canViewHiddenComments() )
				{
					$col = $topic::$databasePrefix . Post::$databaseColumnMap['hidden'];
					$customPaginationWhere[] = array( "( {$col}=0 OR ( {$col}=1 AND ( {$authorCol}=" . Member::loggedIn()->member_id . " ) ) )" );
				}
				else
				{
					$col = Post::$databasePrefix . Post::$databaseColumnMap['hidden'];
					$customPaginationWhere[] = array( "{$col}=0" );
				}
				$pagesCount = ceil( Db::i()->select( 'COUNT(*)', 'forums_posts', $customPaginationWhere )->first() / $topic->getCommentsPerPage() );
			}
			else
			{
				$pagesCount = $topic->commentPageCount();
			}
		}

		if ( Member::loggedIn()->getLayoutValue( 'forum_topic_view_firstpost' ) )
		{
			$firstPost = $topic->comments( 1, 0 );
			$page = ( isset( Request::i()->page ) ) ? intval( Request::i()->page ) : 1;

			if( $page < 1 )
			{
				$page = 1;
			}

			$offset	= ( ( $page - 1 ) * $topic::getCommentsPerPage() ) + 1;

			$pagination = ( $topic->commentPageCount() > 1 ) ? $topic->commentPagination( array( 'sortby' ), 'pagination', $pagesCount ) : NULL;
		}
		else
		{
			$pagination = ( $pagesCount > 1 ) ? $topic->commentPagination( $paginationKeys, 'pagination', $pagesCount ) : NULL;
		}

		$where = Bridge::i()->modifyTopicCommentFilter( $topic, $where );

		$comments = $topic->comments( NULL, $offset, $order, $orderDirection, NULL, NULL, NULL, $where, FALSE, ( isset( Request::i()->showDeleted ) ) );
		$current  = current( $comments );
		reset( $comments );

		if( !count( $comments ) and !Request::i()->show == 'helpful' and ! Member::loggedIn()->getLayoutValue( 'forum_topic_view_firstpost' ) )
		{
			Output::i()->error( 'no_posts_returned', '2F173/L', 404, '' );
		}

		/* Mark read */
		if( !$topic->isLastPage() AND $topic->unread() !== 0 )
		{
			$maxTime	= 0;

			foreach( $comments as $comment )
			{
				$maxTime	= ( $comment->mapped('date') > $maxTime ) ? $comment->mapped('date') : $maxTime;
			}

			if( $topic->timeLastRead() === NULL OR $maxTime > $topic->timeLastRead()->getTimestamp() )
			{
				$topic->markRead( NULL, $maxTime );
			}
		}
		elseif( $topic->isLastPage() )
		{
			/* See if the last comment is hidden or pending approval. If so, force the topic read because it won't be done so automatically. */
			$lastComment = end( $comments );

			if( $lastComment and $lastComment->hidden() !== 0 )
			{
				$topic->markRead( NULL, NULL, NULL, TRUE );
			}

			reset( $comments );
		}

		/* Preload the follow state of the viewing member and any community experts in this forum, this avoids multiple queries */
		$viewerIsFollowingTheseExperts = [];
		if ( Member::loggedIn()->member_id and $experts = $topic->container()->getExperts() )
		{
			$authorsInCommentsThatAreExperts = [];
			foreach ( $comments as $comment )
			{
				if ( in_array( $comment->mapped('author'), $experts ) )
				{
					$authorsInCommentsThatAreExperts[] = $comment->mapped('author');
				}
			}

			if ( count( $authorsInCommentsThatAreExperts ) )
			{
				/* Get the follow state of viewer to experts */
				foreach( Member::loggedIn()->isFollowing( $authorsInCommentsThatAreExperts ) as $expertId => $following )
				{
					if( $following )
					{
						$viewerIsFollowingTheseExperts[] = $expertId;
					}
				}
			}
		}

		/* A convenient hook point to do any further set up */
		$this->finishManage( $topic );

		/* Online User Location */
		Session::i()->setLocation( $topic->url(), ( $topic->container()->password or !$topic->container()->can_view_others ) ? 0 : $topic->onlineListPermissions(), 'loc_forums_viewing_topic', array( $topic->title => FALSE ) );

		/* Next unread */
		try
		{
			$nextUnread	= $topic->containerHasUnread();
		}
		catch( Exception $e )
		{
			$nextUnread	= NULL;
		}

		/* Sidebar? */
		if ( !$topic->isArchived() AND $topic->showSummaryOnDesktop() == 'sidebar' )
		{
			Output::i()->sidebar['contextual'] = Theme::i()->getTemplate( 'topics' )->activity( $topic, 'sidebar' );
		}

		/* Add Json-LD */
		$isQuestion = ( $topic->isSolved() );

		/* The purpose of QAPage is to provide data on the question and suggested answer (if avaiable), or all comments if there is no answer.
		   However, a very large topic that has not been solved, is likely to confuse Google ("The QAPage type indicates that the page is focused on a specific question and its answer(s)"),
		   so if the topic is very long, we will only show this metadata if the topic has been solved. */
		/** @noinspection PhpUndefinedConstantInspection */
		if ( $isQuestion and ( $topic->posts < LARGE_TOPIC_REPLIES or $topic->isSolved() ) )
		{
			Output::i()->jsonLd['topic'] = array(
				'@context'		=> "https://schema.org",
				'@type'			=> 'QAPage',
				'@id'			=> (string) $topic->url(),
				'url'			=> (string) $topic->url(),
				'mainEntity' => [
					'@type'	=> "Question",
					'name'	=> $topic->title,
					'text'  => $firstPost ? $firstPost->truncated( TRUE, NULL ) : $topic->comments( 1, 0 )->truncated( TRUE, NULL ),
					'dateCreated' => DateTime::ts( $topic->start_date )->format( DateTimeInterface::ATOM ),
					'answerCount' => ( ( $topic->posts > 1 ) ? ( $topic->posts - 1 ) : 0 ),
					'author' => [
						'@type' => 'Person',
						'name' => $topic->author()->name,
						'image'	=> $topic->author()->get_photo( TRUE, TRUE )
					]
				]
			);

			if( $topic->author()->member_id )
			{
				Output::i()->jsonLd['topic']['mainEntity']['author']['url'] = (string) $topic->author()->url();
			}

			if( $topic->topic_answered_pid )
			{
				try
				{
					$postClass = '\IPS\forums\Topic\Post';
		
					if( $topic->isArchived() )
					{
						$postClass = '\IPS\forums\Topic\ArchivedPost';
					}
					
					$answer = $postClass::load( $topic->topic_answered_pid );
					
					/* Set up our column names */
					/* @var $databaseColumnMap array */
					$authorIdColumn = $answer::$databaseColumnMap['author'];
					$dateColumn = $answer::$databaseColumnMap['date'];
					
					if ( $truncatedAnswer = $answer->truncated( TRUE, NULL ) )
					{
						Output::i()->jsonLd['topic']['mainEntity']['acceptedAnswer'] = array(
							'@type'		=> 'Answer',
							'text'		=> $truncatedAnswer,
							'url'		=> (string) $answer->url(),
							'dateCreated' => DateTime::ts( $answer->$dateColumn )->format( DateTimeInterface::ATOM ),
							'author'	=> array(
								'@type'		=> 'Person',
								'name'		=> Member::load( $answer->$authorIdColumn )->name,
								'image'		=> Member::load( $answer->$authorIdColumn )->get_photo( TRUE, TRUE )
							),
						);
	
						if( $answer->author_id )
						{
							Output::i()->jsonLd['topic']['mainEntity']['acceptedAnswer']['author']['url']	= (string) Member::load( $answer->$authorIdColumn )->url();
						}
					}
				}
				catch( OutOfRangeException $e ){}
			}
			else if ( $topic->posts > 1 )
			{
				/* We have no accepted answer, but we have replies, so google still wants to see here some meta data, so let's show the last comment https://developers.google.com/search/docs/appearance/structured-data/qapage#answercount */
				try
				{
					$lastComment = $topic->comments( 1, 0, 'date', 'desc' );

					/* Set up our column names */
					/* @var $databaseColumnMap array */
					$authorIdColumn = $lastComment::$databaseColumnMap['author'];
					$dateColumn = $lastComment::$databaseColumnMap['date'];

					Output::i()->jsonLd['topic']['mainEntity']['suggestedAnswer'] = array(
						'@type'		=> 'Answer',
						'text'		=> $lastComment->truncated( TRUE, NULL ),
						'url'		=> (string) $lastComment->url(),
						'dateCreated' => DateTime::ts( $lastComment->$dateColumn )->format( DateTime::ISO8601 ),
						'author'	=> array(
						'@type'		=> 'Person',
						'name'		=> Member::load( $lastComment->$authorIdColumn )->name,
						'image'		=> Member::load( $lastComment->$authorIdColumn )->get_photo( TRUE, TRUE )
						),
					);
				}
				catch( OutOfRangeException $e ){}
			}
		}
		else
		{
			Output::i()->jsonLd['topic'] = array(
				'@context'		=> "https://schema.org",
				'@type'			=> 'DiscussionForumPosting',
				'@id'			=> (string) $topic->url(),
				'isPartOf'		=> array(
					'@id' => Settings::i()->base_url . '#website'
				),
				'publisher'		=> array(
					'@id' => Settings::i()->base_url . '#organization'
				),
				'url'			=> (string) $topic->url(),
				'discussionUrl'	=> (string) $topic->url(),
				'mainEntityOfPage' => array(
					'@type'	=> 'WebPage',
					'@id'	=> (string) $topic->url()
				),
				'pageStart'		=> 1,
				'pageEnd'		=> $topic->commentPageCount(),
			);
		}

		/* Add in comments */
		if( $topic->posts > 1 )
		{
			if ( $isQuestion )
			{
				Output::i()->jsonLd['topic']['mainEntity']['suggestedAnswer'] = [];
			}
			else
			{
				Output::i()->jsonLd['topic']['comment'] = [];
			}

			$i = 0;
			$commentJson = [];
			foreach( $comments as $comment )
			{
				/* Set up our column names */
				$idColumn = $comment::$databaseColumnId;
				/* @var array $databaseColumnMap */
				$authorIdColumn = $comment::$databaseColumnMap['author'];
				$dateColumn = $comment::$databaseColumnMap['date'];
				
				// Don't include the first post as a "comment"
				if( $comment->$idColumn == $topic->topic_firstpost )
				{
					continue;
				}

				// Don't include the answer as the suggested answer
				if( $isQuestion and ( $comment->$idColumn === $topic->mapped('solved_comment_id') ) )
				{
					continue;
				}

				if ( $truncatedComment = $comment->truncated( TRUE, NULL ) )
				{
					$url = $topic->url();
					if( isset( Request::i()->page ) and is_numeric( Request::i()->page ) )
					{
						$url = $url->setPage( 'page', Request::i()->page );
					}

					$commentJson[$i] = array(
						'@type' => $isQuestion ? 'Answer' : 'Comment',
						'@id' => (string)$url->setFragment( 'comment-' . $comment->$idColumn ),
						'url' => (string)$url->setFragment( 'comment-' . $comment->$idColumn ),
						'author' => array(
							'@type' => 'Person',
							'name' => Member::load( $comment->$authorIdColumn )->name,
							'image' => Member::load( $comment->$authorIdColumn )->get_photo( TRUE, TRUE )
						),
						'dateCreated' => DateTime::ts( $comment->$dateColumn )->format( DateTimeInterface::ATOM ),

						'text' => $truncatedComment,
					);

					if ( $comment->$authorIdColumn )
					{
						$commentJson[$i]['author']['url'] = (string)Member::load( $comment->$authorIdColumn )->url();
					}

					$commentJson[$i]['upvoteCount'] = $comment->reactionCount();

					$i++;
				}
			}

			if ( $isQuestion )
			{
				Output::i()->jsonLd['topic']['mainEntity']['suggestedAnswer'] = $commentJson;
			}
			else
			{
				Output::i()->jsonLd['topic']['comment'] = $commentJson;
			}
		}

		/* Do we have a real author */
		if( $topic->starter_id )
		{
			Output::i()->jsonLd['topic']['author']['url'] = (string) Member::load( $topic->starter_id )->url();

			Output::i()->jsonLd['topic']['publisher']['member'] = array(
				'@type'		=> "Person",
				'name'		=> Member::load( $topic->starter_id )->name,
				'image'		=> (string) Member::load( $topic->starter_id )->get_photo( TRUE, TRUE ),
				'url'		=> (string) Member::load( $topic->starter_id )->url(),
			);
		}

		/* Enable caching for archived topics */
		if( $topic->isArchived() AND !Member::loggedIn()->member_id )
		{
			/* We do not want to use the \IPS\CACHE_PRIVATE_TIMEOUT constant here, as we explicitly want to cache archived topics for longer times */
			$httpHeaders = array( 'Expires'		=> DateTime::create()->add( new DateInterval( 'PT12H' ) )->rfc1123() ,
								  'Cache-Control'	=> 'no-cache="Set-Cookie", max-age=' . ( 60 * 60 * 12 ) . ", s-maxage=" . ( 60 * 60 * 12 ) . ", public, stale-if-error, stale-while-revalidate" );

			Output::i()->httpHeaders += $httpHeaders;
		}

		/* $current might be null if the topic has no replies AND we are featuring the first post */
		if( empty( $current ) and Member::loggedIn()->getLayoutValue( 'forum_topic_view_firstpost' ) )
		{
			$current = $firstPost;
		}

		/* Set og:image meta tags */
		if( count( $file = $topic->imageAttachments(1 ) ) )
		{
			$object = File::get( 'core_Attachment', $file[0]['attach_location'] );
			Output::i()->metaTags['og:image'] = (string) $object->url->setScheme( ( mb_substr( Settings::i()->base_url, 0, 5 ) === 'https' ) ? 'https' : 'http' );
		}
		
		if( $current )
		{
			Output::i()->jsonLd['topic'] = array_merge_recursive( array(
				'name'			=> $topic->mapped('title'),
				'headline'		=> $topic->mapped('title'),
				'text'			=> $current->truncated( TRUE, NULL ),
				'dateCreated'	=> DateTime::ts( $topic->start_date )->format( DateTimeInterface::ATOM ),
				'datePublished'	=> DateTime::ts( $topic->start_date )->format( DateTimeInterface::ATOM ),
				'dateModified'	=> DateTime::ts( $topic->last_post )->format( DateTimeInterface::ATOM ),
				'author'		=> array(
					'@type'		=> 'Person',
					'name'		=> Member::load( $topic->starter_id )->name,
					'image'		=> Member::load( $topic->starter_id )->get_photo( TRUE, TRUE )
				),
				'interactionStatistic'	=> array(
					array(
						'@type'					=> 'InteractionCounter',
						'interactionType'		=> "https://schema.org/ViewAction",
						'userInteractionCount'	=> $topic->views
					),
					array(
						'@type'					=> 'InteractionCounter',
						'interactionType'		=> "https://schema.org/CommentAction",
						'userInteractionCount'	=> $topic->posts - 1 // We subtract one to account for the "first post"
					)
				)
			), Output::i()->jsonLd['topic'] );

			if( isset( 	Output::i()->metaTags['og:image'] ) )
			{
				Output::i()->jsonLd['topic']['image'] = Output::i()->metaTags['og:image'];
			}
		}

		if( !$topic->isArchived() )
		{
			Output::i()->jsonLd['topic']['interactionStatistic'][] = [
				'@type'					=> 'InteractionCounter',
				'interactionType'		=> "http://schema.org/FollowAction",
				'userInteractionCount'	=> $topic->followersCount()
			];
		}

		/* Noindex if helpful filter applied */
		if( isset( Request::i()->show ) )
		{
			Output::i()->metaTags['robots'] = 'noindex';
		}

		/* Noindex for archiving if setting is enabled */
		if( $topic->isArchived() and Settings::i()->archive_noindex )
		{
			Output::i()->metaTags['robots'] = 'noindex, follow';
		}

		/* Set default search to this topic */
		Output::i()->defaultSearchOption = array( 'forums_topic', 'forums_topic_el' );

		/* Show topic */
		Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'topics.css', 'forums' ) );
		Output::i()->output .= Theme::i()->getTemplate( 'topics' )->topic( $topic, $comments, $nextUnread, $pagination, $firstPost, $viewerIsFollowingTheseExperts );
		return null;
	}

	/**
	 * Check our view method and act accordingly (redirect if appropriate)
	 *
	 * @return	void
	 */
	protected function _doViewCheck() : void
	{
		try
		{
			/* @var TopicClass $class */
			$class	= static::$contentModel;
			$topic	= $class::loadAndCheckPerms( Request::i()->id );
			
			switch( Request::i()->view )
			{
				case 'getnewpost':
					Output::i()->redirect( $topic->url( 'getNewComment' ) );
				break;
				
				case 'getlastpost':
					Output::i()->redirect( $topic->url( 'getLastComment' ) );
				break;
			}
		}
		catch( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/F', 403, '' );
		}
	}
	
	/**
	 * Edit topic
	 *
	 * @return	void
	 */
	public function edit() : void
	{
		try
		{
			/* @var TopicClass $class */
			$class = static::$contentModel;
			$topic = $class::loadAndCheckPerms( Request::i()->id );
			$forum = $topic->container();
			$forum->setTheme();
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'no_module_permission', '2F173/D', 403, 'no_module_permission_guest' );
		}
		
		// We check if the form has been submitted to prevent the user loosing their content
		if ( isset( Request::i()->form_submitted ) )
		{
			if ( ! $topic->couldEdit() )
			{
				Output::i()->error( 'edit_no_perm_err', '2F173/E', 403, '' );
			}
		}
		else
		{
			if ( ! $topic->canEdit() )
			{
				Output::i()->error( 'edit_no_perm_err', '2F173/E', 403, '' );
			}
		}
		
		$formElements = $class::formElements( $topic, $forum );

		$hasModOptions = FALSE;
		/* We used to just check against the ability to lock, however this may not be enough - a moderator could pin, for example, but not lock */
		foreach( array( 'lock', 'pin', 'feature' ) AS $perm )
		{
			if ( $class::modPermission( $perm, NULL, $forum ) )
			{
				$hasModOptions = TRUE;
				break;
			}
		}
		if( $topic->canHide() )
		{
		    $hasModOptions = TRUE;
		}
		
		$form = $topic->buildEditForm();
		
		if ( $values = $form->values() )
		{
			if ( $topic->canEdit() )
			{
				/* @var $databaseColumnMap array */
				$titleField = $topic::$databaseColumnMap['title'];
				$oldTitle = $topic->$titleField;

                $topic->processBeforeEdit( $values );
				$topic->processForm( $values );
				$topic->save();
				$topic->processAfterEdit( $values );

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

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

		$formTemplate = $form->customTemplate( array( Theme::i()->getTemplate( 'submit', 'forums' ), 'createTopicForm' ), $forum, $hasModOptions, $topic );

		$title = 'edit_topic';

		Output::i()->sidebar['enabled'] = FALSE;
		Output::i()->output = Theme::i()->getTemplate( 'submit' )->createTopic( $formTemplate, $forum, $title );
		Output::i()->title = Member::loggedIn()->language()->addToStack( $title );
		
		if ( !Forum::theOnlyForum() and ! Forum::isSimpleView() )
		{
			try
			{
				foreach( $forum->parents() AS $parent )
				{
					Output::i()->breadcrumb[] = array( $parent->url(), $parent->_title );
				}
				Output::i()->breadcrumb[] = array( $forum->url(), $forum->_title );
			}
			catch( Exception $e ) {}
		}
		
		Output::i()->breadcrumb[] = array( $topic->url(), $topic->mapped('title') );
		Output::i()->breadcrumb[] = array( NULL, Member::loggedIn()->language()->addToStack( $title ) );
	}

	/**
	 * Unarchive
	 *
	 * @return	void
	 */
	public function unarchive() : void
	{
		Session::i()->csrfCheck();
		
		try
		{
			$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
			if ( !$topic->canUnarchive() )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/B', 404, '' );
		}
		
		$topic->topic_archive_status = TopicClass::ARCHIVE_RESTORE;
		$topic->save();
		
		/* Make sure the task is enabled */
		Db::i()->update( 'core_tasks', array( 'enabled' => 1 ), array( '`key`=?', 'unarchive' ) );

		/* Log */
		Session::i()->modLog( 'modlog__unarchived_topic', array( $topic->url()->__toString() => FALSE, $topic->mapped( 'title' ) => FALSE ), $topic );
		
		Output::i()->redirect( $topic->url() );
	}

	/**
	 * Remove the archive exclude flag
	 *
	 * @return void
	 */
	public function removeArchiveExclude() : void
	{
		Session::i()->csrfCheck();

		try
		{
			$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
			if ( !$topic->canRemoveArchiveExcludeFlag() )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/P', 404, '' );
		}

		$topic->topic_archive_status = TopicClass::ARCHIVE_NOT;
		$topic->save();

		/* Log */
		Session::i()->modLog( 'modlog__removed_archive_exclude_topic', array( $topic->url()->__toString() => FALSE, $topic->mapped( 'title' ) => FALSE ), $topic );

		Output::i()->redirect( $topic->url() );
	}
	
	/**
	 * Set Best Answer
	 *
	 * @return	void
	 */
	public function bestAnswer() : void
	{
		Session::i()->csrfCheck();
		
		try
		{
			$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
			$post = Post::loadAndCheckPerms( Request::i()->answer );
			
			if ( !$topic->canSetBestAnswer() )
			{
				throw new OutOfRangeException;
			}
			
			if ( $post->item() !== $topic )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/7', 404, '' );
		}

		$topic->toggleSolveComment( $post->pid, TRUE );
		
		/* Log */
		if ( Member::loggedIn()->modPermission('can_set_best_answer') )
		{
			Session::i()->modLog( 'modlog__best_answer_set', array( $post->pid => FALSE ), $topic );
		}
		
		Output::i()->redirect( $post->url() );
	}
	
	/**
	 * Unset Best Answer
	 *
	 * @return	void
	 */
	public function unsetBestAnswer() : void
	{
		Session::i()->csrfCheck();
		
		try
		{
			$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
			$post = Post::loadAndCheckPerms( Request::i()->answer );
			
			if ( !$topic->canSetBestAnswer() )
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'node_error', '2F173/G', 404, '' );
		}

		if ( $post->item() !== $topic )
		{
			throw new OutOfRangeException;
		}

		try
		{
			$topic->toggleSolveComment( $post->pid, FALSE );
			
			if ( Member::loggedIn()->modPermission('can_set_best_answer') )
			{
				Session::i()->modLog( 'modlog__best_answer_unset', array( $post->pid => FALSE ), $topic );
			}
		}
		catch ( Exception $e ) {}
	
		Output::i()->redirect( $post->url() );
	}
	
	/**
	 * Saved Action
	 *
	 * @return	void
	 */
	public function savedAction() : void
	{
		try
		{
			Session::i()->csrfCheck();
			
			$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
			$action = SavedAction::load( Request::i()->action );
			$action->runOn( $topic );
			
			/* Log */
			Session::i()->modLog( 'modlog__saved_action', array( 'forums_mmod_' . $action->mm_id => TRUE, $topic->url()->__toString() => FALSE, $topic->mapped( 'title' ) => FALSE ), $topic );
			Output::i()->redirect( $topic->url() );
		}
		catch ( LogicException $e )
		{
			
		}
	}

	/**
	 * Mark Topic Read
	 *
	 * @return	void
	 */
	public function markRead() : void
	{
		Session::i()->csrfCheck();
		
		try
		{
			$topic = TopicClass::load( Request::i()->id );
			$topic->markRead();

			if ( Request::i()->isAjax() )
			{
				Output::i()->json( "OK" );
			}
			else
			{
				Output::i()->redirect( $topic->url() );
			}
		}
		catch ( OutOfRangeException $e )
		{
			Output::i()->error( 'no_module_permission', '2F173/C', 403, 'no_module_permission_guest' );
		}
	}
	
	/**
	 * We need to use the custom widget poll template for ajax methods
	 *
	 * @return void
	 */
	public function widgetPoll() : void
	{
		try
		{
			$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
		}
		catch( OutOfRangeException $ex )
		{
			Output::i()->error( 'node_error', '2F173/N', 403, '' );
		}
		
		$poll  = $topic->getPoll();
		$poll->displayTemplate = array( Theme::i()->getTemplate( 'widgets', 'forums', 'front' ), 'pollWidget' );
		$poll->url = $topic->url();
		
		Output::i()->output .= Theme::i()->getTemplate( 'widgets', 'forums', 'front' )->poll( $topic, $poll );
	}

	/**
	 * Show a single comment requested by ajax
	 *
	 * @return	void
	 */
	public function ajaxShowComment() : void
	{
		try
		{
			if ( ! Request::i()->isAjax() )
			{
				throw new BadMethodCallException();
			}

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

			try
			{
				$topic = TopicClass::loadAndCheckPerms( Request::i()->id );
			}
			catch( OutOfRangeException $ex )
			{
				Output::i()->error( 'node_error', '2F173/N', 403, '' );
			}

			$comment = Post::load( Request::i()->showComment );

			if ( ! $comment->canView() )
			{
				throw new BadMethodCallException();
			}

			Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core' )->blankTemplate( Theme::i()->getTemplate( 'global', 'core' )->commentContainer( $topic, $comment ), 200, 'text/html' ) );
		}
		catch( Exception $e )
		{
			
		}
	}
	
	/**
	 * 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
	{
		Member::loggedIn()->language()->words['edit_comment']		= Member::loggedIn()->language()->addToStack( 'edit_reply', FALSE );

		parent::_edit( $commentClass, $comment, $item );
	}
	
	/**
	 * Stuff that applies to both comments and reviews
	 *
	 * @param	string	$method	Desired method
	 * @param	array	$args	Arguments
	 */
	public function __call( string $method, mixed $args )
	{
		$class = static::$contentModel;
		
		try
		{
			/* @var TopicClass $class */
			$item = $class::load( Request::i()->id );
			if ( !$item->canView() )
			{
				$forum = $item->container();
				Output::i()->error( $forum ? $forum->errorMessage() : 'node_error_no_perm', '2F173/K', 403, '' );
			}
			
			if ( $item->isArchived() )
			{
				$class::$commentClass = $class::$archiveClass;
			}
			
			return parent::__call( $method, $args );
		}
		catch( OutOfRangeException $e )
		{
			if ( isset( Request::i()->do ) AND Request::i()->do === 'findComment' AND isset( Request::i()->comment ) )
			{
				try
				{
					/* @var Comment $commentClass */
					$commentClass = $class::$commentClass;
					$comment = $commentClass::load( Request::i()->comment );
					$topic   = TopicClass::load( $comment->topic_id );
					
					Output::i()->redirect( $topic->url()->setQueryString( array( 'do' => 'findComment', 'comment' => Request::i()->comment ) ), NULL, 301 );
				}
				catch( Exception $e )
				{
					Output::i()->error( 'node_error', '2F173/M', 404, '' );
				}
			}
		}
		catch ( Exception $e )
		{
			Log::log( $e, 'topic_call' );
			Output::i()->error( 'node_error', '2F173/I', 404, '' );
		}
		
		return null;
	}

	/**
	 * Form for splitting
	 *
	 * @param Item $item The item
	 * @param null $comment
	 * @return    Form
	 */
	protected function _splitForm( Item $item, $comment = NULL  ) : Form
	{
		$form = parent::_splitForm( $item, $comment );

		if ( isset( $form->elements['']['topic_create_state'] ) )
		{
			unset( $form->elements['']['topic_create_state'] );
		}
		
		return $form;
	}

    protected function finishManage( Item $item ): void
    {
        Bridge::i()->topicsFinishManage( $item );
    }

    /**
     * @return void
     */
    protected function splitQuestion(): void
    {
        Bridge::i()->topicsSplitQuestion( $this );
    }

	/**
	 * @return void
	 */
    protected function getQuestionQuoteDataForEditor(): void
    {
        Bridge::i()->getQuestionQuoteDataForEditor();
    }

	/**
	 * 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
	{
		if ( Bridge::i()->featureIsEnabled( 'topic_summaries' ) )
		{
			$idField = $comment::$databaseColumnId;
			Bridge::i()->trackPostRankingEvent( $comment->$idField, 'linked' );
		}
		parent::_find( $commentClass, $comment, $item );
	}

	/**
	 * 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
	{
		if ( Bridge::i()->featureIsEnabled( 'topic_summaries' ) )
		{
			try
			{
				Session::i()->csrfCheck();
				Reaction::load( Request::i()->reaction ); // we do this to make sure the reaction exists; At this point in the core method the comment is reacted upon @todo consider making this part of the listener system
				$idField = $comment::$databaseColumnId;
				Bridge::i()->trackPostRankingEvent( $comment->$idField, 'reaction' );
			}
			catch ( Throwable ) {}
		}
		parent::_react( $commentClass, $comment, $item );
	}

	/**
	 * Add or remove the post from the summary
	 * @return void
	 */
	protected function addOrRemovePostFromSummary() : void
	{
		Bridge::i()->addOrRemovePostFromSummary();
	}
}