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

namespace IPS\Content;

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

use BadMethodCallException;
use IPS\Application;
use IPS\Content\Search\Index;
use IPS\Content\Search\SearchContent;
use IPS\core\Approval;
use IPS\DateTime;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Member;
use IPS\Theme;
use OutOfRangeException;
use function defined;
use function get_called_class;
use function get_class;
use function in_array;
use function is_array;
use function is_null;
use function json_encode;

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

/**
 * Content Review Model
 */
abstract class Review extends Comment
{
	
	/**
	 * @brief	[Content\Comment]	Form Template
	 */
	public static array $formTemplate = array( array( 'forms', 'core', 'front' ), 'reviewTemplate' );

	/**
	 * @brief	[Content\Comment]	Reviews Template
	 */
	public static array $commentTemplate = array( array( 'global', 'core', 'front' ), 'reviewContainer' );

	public static string $moderationMenuIdPrefix = 'review_';

	/**
	 * Create first comment (created with content item)
	 *
	 * @param Item $item The content item just created
	 * @param string $comment The comment
	 * @param bool $first Is the first comment?
	 * @param string|null $guestName If author is a guest, the name to use
	 * @param bool|null $incrementPostCount
	 * @param Member|null $member The author of this comment. If NULL, uses currently logged in member.
	 * @param DateTime|null $time The time
	 * @param string|null $ipAddress The IP address or NULL to detect automatically
	 * @param int|null $hiddenStatus NULL to set automatically or override: 0 = unhidden; 1 = hidden, pending moderator approval; -1 = hidden (as if hidden by a moderator)
	 * @param int|null $anonymous NULL for no value, 0 or 1 for a value (0=no, 1=yes)
	 * @return Review|null
	 */
	public static function create( Item $item, string $comment, bool $first=false, string|null $guestName=null, bool|null $incrementPostCount= null, Member|null $member= null, DateTime|null $time= null, string|null $ipAddress= null, int|null $hiddenStatus= null, int|null $anonymous= null ): static|null
	{
		$obj = parent::create( $item, $comment, $first, $guestName, null, $member, $time, $ipAddress, $hiddenStatus );
		
		if ( isset( static::$databaseColumnMap['votes_data'] ) )
		{
			/* Make sure we start with a valid json array */
			$val = json_encode( [] );

			foreach ( is_array( static::$databaseColumnMap['votes_data'] ) ? static::$databaseColumnMap['votes_data'] : array( static::$databaseColumnMap['votes_data'] ) as $column )
			{
				$obj->$column = $val;
			}
		}

		if( $obj->author()->member_id AND $obj::incrementPostCount() )
		{
			$obj->author()->member_last_post = time();
			$obj->author()->save();
		}

		$obj->save();

		/* Have to do these AFTER rating is set */
		$itemClass = static::$itemClass;
		/* @var array $databaseColumnMap */
		$ratingField = $itemClass::$databaseColumnMap['rating'];

		$obj->item()->$ratingField = $obj->item()->averageReviewRating() ?: 0;
		$obj->item()->save();

		/* Send notifications and dish out points */
		if ( !$obj->hidden() and ( !$first or !$item::$firstCommentRequired ) )
		{
			$obj->sendNotifications();
			$obj->author()->achievementAction( 'core', 'Review', $obj );
		}
		else if( $obj->hidden() === 1 )
		{
			$obj->sendUnapprovedNotification();
		}
		
		return $obj;
	}
	
	/**
	 * Do stuff after creating (abstracted as comments and reviews need to do different things)
	 *
	 * @return	void
	 */
	public function postCreate(): void
	{		
		$item = $this->item();
		if ( !$this->hidden() )
		{
			if ( isset( $item::$databaseColumnMap['last_review'] ) )
			{
				$lastReviewField = $item::$databaseColumnMap['last_review'];
				if ( is_array( $lastReviewField ) )
				{
					foreach ( $lastReviewField as $column )
					{
						$item->$column = time();
					}
				}
				else
				{
					$item->$lastReviewField = time();
				}
			}
			if ( isset( $item::$databaseColumnMap['last_review_by'] ) )
			{
				$lastReviewByField = $item::$databaseColumnMap['last_review_by'];
				$item->$lastReviewByField = $this->author()->member_id;
			}
			if ( isset( $item::$databaseColumnMap['last_review_name'] ) )
			{
				$lastReviewNameField = $item::$databaseColumnMap['last_review_name'];
				$item->$lastReviewNameField = $this->author()->name;
			}
			if ( isset( $item::$databaseColumnMap['num_reviews'] ) )
			{
				$numReviewsField = $item::$databaseColumnMap['num_reviews'];
				$item->$numReviewsField++;
			}
			
			if ( !$item->hidden() and $item->containerWrapper() and $item->container()->_reviews !== NULL )
			{
				$item->container()->_reviews = ( $item->container()->_reviews + 1 );
				$item->container()->setLastReview( $this );
				$item->container()->save();
			}
		}
		else
		{
			if ( isset( $item::$databaseColumnMap['unapproved_reviews'] ) )
			{
				$numReviewsField = $item::$databaseColumnMap['unapproved_reviews'];
				$item->$numReviewsField++;
			}
			if ( $item->containerWrapper() AND $item->container()->_unapprovedReviews !== NULL )
			{
				$item->container()->_unapprovedReviews = $item->container()->_unapprovedReviews + 1;
				$item->container()->save();
			}
		}
		
		$item->save();
		
		/* Was it moderated? Let's see why. */
		if ( $this->hidden() === 1 )
		{
			$idColumn = static::$databaseColumnId;
			
			/* Check we don't already have a reason from profanity / url / email filters */
			try
			{
				Approval::loadFromContent( get_called_class(), $this->$idColumn );
			}
			catch( OutOfRangeException $e )
			{
				/* If the user is mod-queued - that's why. These will cascade, so check in that order. */
				$reason = null;
				if ( $this->author()->mod_posts )
				{
					$reason	= 'user';
				}
				
				/* If the user isn't mod queued, but is in a group that is, that's why. */
				if ( $reason === null AND $this->author()->group['g_mod_preview'] )
				{
					$reason	= 'group';
				}
				
				/* If the user isn't on mod queue, but the container requires approval, that's why. */
				if ( $reason === null )
				{
					try
					{
						if ( $item->container() AND $item->container()->contentHeldForApprovalByNode( 'review', $this->author() ) === TRUE )
						{
							$reason = 'node';
						}
					}
					catch( BadMethodCallException $e ) { }
				}
				
				/* Finally if the item itself requires moderation, that's why */
				if (
					$reason === null AND
					IPS::classUsesTrait( $item, 'IPS\Content\MetaData' ) AND
					is_array( $item::supportedMetaDataTypes() ) AND
					in_array('core_ItemModeration', $item::supportedMetaDataTypes() ) AND
					Application::load('core')->extensions( 'core', 'MetaData' )['ItemModeration']->enabled( $item, $this->author() )
				)
				{
					$reason = 'item';
				}
				
				if ( $reason )
				{
					Approval::create( $this, $reason );
				}
			}
		}
	}

	/**
	 * @brief	Value to set for the 'tab' parameter when redirecting to the comment (via _find())
	 */
	public static ?array $tabParameter	= array( 'tab' => 'reviews' );

	/**
	 * Get URL
	 *
	 * @param	string|null		$action		Action
	 * @return	Url
	 */
	public function url( string|null $action='find' ): Url
	{
		$idColumn = static::$databaseColumnId;

		return $this->item()->url()->setQueryString( array(
			'do'		=> $action . 'Review',
			'review'	=> $this->$idColumn
		) );
	}
	
	/**
	 * Get line which says how many users found review helpful
	 *
	 * @return	string
	 */
	public function helpfulLine(): string
	{
		return Theme::i()->getTemplate( 'global', 'core', 'front' )->reviewHelpful( $this->mapped('votes_helpful'), $this->mapped('votes_total') );
	}
	
	/**
	 * Edit and existing rating
	 *
	 * @param	int		$rating		The new rating
	 * @return void
	 */
	public function editRating( int $rating ): void
	{
		/* @var array $databaseColumnMap */

		/* Review */
		$ratingField = static::$databaseColumnMap['rating'];
		$this->$ratingField = $rating;
		$this->save();
		
		/* Item */
		$item = $this->item();
		$ratingField = $item::$databaseColumnMap['rating'];
		$item->$ratingField = $item->averageReviewRating() ?: 0; 

		$item->resyncLastReview();
		$item->save();
	}
	
	/**
	 * Can split this comment off?
	 *
	 * @param	Member|null	$member	The member to check for (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canSplit( Member|null $member=null ): bool
	{
		return FALSE;
	}

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

		if( $member === NULL )
		{
			$member	= Member::loggedIn();
		}

		if ( IPS::classUsesTrait( $this, 'IPS\Content\Hideable' ) and $this->hidden() and !$this->item()->canViewHiddenReviews( $member ) and ( $this->hidden() !== 1 or $this->author() !== $member ) )
		{
			return FALSE;
		}

		return $this->item()->canView( $member );
	}
	
	/**
	 * Warning Reference Key
	 *
	 * @return	string
	 */
	public function warningRef(): string
	{
		/* If the member cannot warn, return NULL, so we're not adding ugly parameters to the profile URL unnecessarily */
		if ( !Member::loggedIn()->modPermission('mod_can_warn') )
		{
			return '';
		}
		
		$itemClass = static::$itemClass;
		$idColumn = static::$databaseColumnId;
		return base64_encode( json_encode( array( 'app' => $itemClass::$application, 'module' => $itemClass::$module . '-review' , 'id_1' => $this->mapped('item'), 'id_2' => $this->$idColumn ) ) );
	}
	
	/**
	 * Get attachment IDs
	 *
	 * @return	array
	 */
	public function attachmentIds(): array
	{
		$item = $this->item();
		$idColumn = $item::$databaseColumnId;
		$commentIdColumn = static::$databaseColumnId;
		return array( $this->item()->$idColumn, $this->$commentIdColumn, 'review' ); 
	}
	
	/**
	 * Delete Review
	 *
	 * @return    void
	 */
	public function delete(): void
	{
		parent::delete();
		$this->_recalculateRating();
	}

	/**
	 * Recalculate rating after review action
	 *
	 * @return void
	 */
	protected function _recalculateRating(): void
	{
		$itemClass = static::$itemClass;
		/* @var array $databaseColumnMap */
		$ratingField = $itemClass::$databaseColumnMap['rating'];
		$this->item()->$ratingField = $this->item()->averageReviewRating() ?: 0;
		$this->item()->save();
	}

	/**
	 * Get output for API
	 *
	 * @param Member|null  $authorizedMember The member making the API request or NULL for API Key / client_credentials
	 * @return    array
	 * @apiresponse		int			id				ID number
	 * @apiresponse		int			item			The ID number of the item this belongs to
	 * @apiresponse		\IPS\Member	author			Author
	 * @apiresponse		datetime	date			Date
	 * @apiresponse		int			rating			The number of stars this review gave
	 * @apiresponse		int			votesTotal		The number of users that have voted if this review was helpful or unhelpful
	 * @apiresponse		int			votesHelpful	The number of users that voted helpful
	 * @apiresponse		string		content			The content
	 * @apiresponse		bool		hidden			Is hidden?
	 * @apiresponse		string		url				URL to content
	 * @apiresponse		string|null	authorResponse	The content item's author's response to the review, if any
	 */
	public function apiOutput( Member|null $authorizedMember = NULL ): array
	{
		$idColumn = static::$databaseColumnId;
		$itemColumn = static::$databaseColumnMap['item'];
		return array(
			'id'				=> $this->$idColumn,
			'item_id'			=> $this->$itemColumn,
			'author'			=> $this->author()->apiOutput( $authorizedMember ),
			'date'				=> DateTime::ts( $this->mapped('date') )->rfc3339(),
			'rating'			=> $this->mapped('rating'),
			'votesTotal'		=> $this->mapped('votes_total'),
			'votesHelpful'		=> $this->mapped('votes_helpful'),
			'content'			=> $this->content(),
			'hidden'			=> (bool) $this->hidden(),
			'url'				=> (string) $this->url(),
			'authorResponse'	=> $this->mapped('author_response')
		);
	}

	/* !Author responses */

	/**
	 * Has the author responded to this review?
	 *
	 * @return bool
	 */
	public function hasAuthorResponse(): bool
	{
		return !is_null( $this->mapped('author_response') );
	}

	/**
	 * Can the specified user respond to the review?
	 *
	 * @note	Only the author of the content item can respond by default, but this is abstracted so third parties can override
	 * @param	Member|NULL	$member	Member to check or NULL for currently logged in member
	 * @return	bool
	 */
	public function canRespond( Member|null $member=NULL ): bool
	{
		/* Make sure it's supported */
		if( !isset( static::$databaseColumnMap['author_response'] ) )
		{
			return false;
		}

		$member = $member ?: Member::loggedIn();

		/* If we have not responded... */
		if( !$this->hasAuthorResponse() )
		{
			/* ...and we are the author of the content item... */
			if( $member->member_id and $member->member_id == $this->item()->author()->member_id )
			{
				/* ...then we can respond to this review. */
				return TRUE;
			}
		}

		return FALSE;
	}

	/**
	 * Can the specified user edit the response to this review?
	 *
	 * @param	Member|null	$member	Member to check or NULL for currently logged in member
	 * @return	bool
	 */
	public function canEditResponse( Member|null $member=null ): bool
	{
		$member = $member ?: Member::loggedIn();

		/* If there is an author response... */
		if( $this->hasAuthorResponse() )
		{
			$item = $this->item();

			/* Moderators who can edit content can edit responses */
			if ( static::modPermission( 'edit', $member, $item->containerWrapper() ) )
			{
				return TRUE;
			}

			/* Or maybe the member can edit their own content? */
			if ( $member->member_id and $member->member_id == $item->author()->member_id and ( $member->group['g_edit_posts'] == '1' or in_array( get_class( $item ), explode( ',', $member->group['g_edit_posts'] ) ) ) )
			{
				if ( IPS::classUsesTrait( $item, 'IPS\Content\Lockable' ) AND $item->locked() )
				{
					return FALSE;
				}
				
				return TRUE;
			}
		}

		/* Nope, we cannot edit */
		return FALSE;
	}

	/**
	 * Can the specified user delete the response to this review?
	 *
	 * @param	Member|NULL	$member	Member to check or NULL for currently logged in member
	 * @return	bool
	 */
	public function canDeleteResponse( Member|null $member=NULL ): bool
	{
		$member = $member ?: Member::loggedIn();

		/* If there is an author response... */
		if( $this->hasAuthorResponse() )
		{
			$container = NULL;

			try
			{
				$container = $this->item()->container();
			}
			catch ( BadMethodCallException $e ) { }

			/* Moderators who can delete content can delete responses */
			if( static::modPermission( 'delete', $member, $container ) )
			{
				return TRUE;
			}

			/* Or maybe the author can delete their own content? */
			if( $member->member_id and $member->member_id == $this->item()->author()->member_id and ( $member->group['g_delete_own_posts'] == '1' or in_array( get_class( $this->item() ), explode( ',', $member->group['g_delete_own_posts'] ) ) ) )
			{
				return TRUE;
			}
		}

		/* Nope, we cannot delete */
		return FALSE;
	}

	/**
	 * Respond to a review
	 *
	 * @param string $response
	 * @return void
	 */
	public function setResponse( string $response ) : void
	{
		$this->checkProfanityFilters( false, $this->hasAuthorResponse(), $response );
		$column = static::$databaseColumnMap['author_response'];
		$this->$column = $response;
		$this->save();

		/* Reindex in case it was hidden by the profanity filters */
		if( SearchContent::isSearchable( $this ) )
		{
			Index::i()->index( $this );
		}
	}

	/**
	 * Get the feed id that should contain reviews
	 *
	 * @return string
	 */
	public function get_feedId() : string
	{
		return $this->item()->reviewFeedId;
	}
}