<?php
/**
 * @brief		Tags trait for Content Models
 * @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		14 Nov 2013
 */

namespace IPS\Content;

use DomainException;
use IPS\Db;
use IPS\Helpers\Form\Text;
use IPS\IPS;
use IPS\Member;
use IPS\Node\Model;
use IPS\Request;
use IPS\Settings;
use OutOfRangeException;
use function defined;
use function get_called_class;
use function header;
use function is_array;
use function json_encode;
use function md5;
use function rawurlencode;
use function time;

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

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

/**
 * Tags trait for Content Models
 *
 * @note	Content classes will gain special functionality by implementing this interface
 */
trait Taggable
{
	/**
	 * Can tag?
	 *
	 * @param	Member|NULL		$member		The member to check for (NULL for currently logged in member)
	 * @param	Model|NULL	$container	The container to check if tags can be used in, if applicable
	 * @return	bool
	 */
	public static function canTag( ?Member $member = NULL, ?Model $container = NULL ): bool
	{
		/* Commenting this out until we decide whether we are allowing
		nodes to have tags enabled/disabled */
		if( $container and !$container->checkAction( 'tags' ) )
		{
			return FALSE;
		}

		/* Are tags enabled? */
		if( !Settings::i()->tags_enabled )
		{
			return false;
		}

		/* Do we have at least one tag set up? */
		if( !count( Tag::getStore() ) )
		{
			return false;
		}

		$member = $member ?: Member::loggedIn();
		return !( $member->group['gbw_disable_tagging'] ) and !( $member->members_bitoptions['bw_disable_tagging'] );
	}
	
	/**
	 * Can use prefixes?
	 *
	 * @param	Member|NULL		$member		The member to check for (NULL for currently logged in member)
	 * @param	Model|NULL	$container	The container to check if tags can be used in, if applicable
	 * @return	bool
	 */
	public static function canPrefix( ?Member $member = NULL, ?Model $container = NULL ): bool
	{
		/* Commenting this out until we decide whether we are allowing
		nodes to have tags enabled/disabled */
		/*if( $container and !$container->checkAction( 'prefix' ) )
		{
			return FALSE;
		}*/

		$member = $member ?: Member::loggedIn();
		return Settings::i()->tags_enabled and Settings::i()->tags_can_prefix and !( $member->group['gbw_disable_tagging'] ) and !( $member->group['gbw_disable_prefixes'] ) and !( $member->members_bitoptions['bw_disable_tagging'] ) and !( $member->members_bitoptions['bw_disable_prefixes'] );
	}
	
	/**
	 * Defined Tags
	 *
	 * @return	array
	 */
	public static function definedTags(): array
	{
		return array_values( Tag::getStore() );
	}
	
	/**
	 * @brief	Tags cache
	 */
	protected ?array $tags = NULL;
	
	/**
	 * Get prefix
	 *
	 * @param	bool		$encode	Encode returned value
	 * @return	string|NULL
	 */
	public function prefix( bool $encode=FALSE ): string|NULL
	{
		if ( $this->tags === NULL )
		{
			$this->tags();
		}
								
		return isset( $this->tags['prefix'] ) ? ( ( $encode ) ? rawurlencode( $this->tags['prefix'] ) : $this->tags['prefix'] ) : NULL;
	}
	
	/**
	 * Get tags
	 *
	 * @return	array
	 */
	public function tags(): array
	{
		if ( Settings::i()->tags_enabled )
		{
			if ( $this->tags === NULL )
			{
				$idColumn = static::$databaseColumnId;
				$this->tags = array( 'tags' => array(), 'prefix' => NULL );

				foreach ( Db::i()->select( '*', 'core_tags', array( 'tag_meta_app=? AND tag_meta_area=? AND tag_meta_id=?', static::$application, static::$module, $this->$idColumn ), 'tag_text' ) as $tag )
				{
					$tagText = Settings::i()->tags_force_lower ? mb_strtolower( $tag['tag_text'] ) : $tag['tag_text'];
					if ( $tag['tag_prefix'] )
					{
						$this->tags['prefix'] =  $tagText;
					}
					else
					{
						$this->tags['tags'][] = $tagText;
					}
				}
			}

			return ($this->tags['tags'] ?? []);
		}
		else
		{
			return [];
		}
	}
	
	/**
	 * Set tags
	 *
	 * @param	array|string				$set	The tags (if one has the key "prefix", it will be set as the prefix)
	 * @param	Member|NULL	$member	The member saving the tags, or NULL for currently logged in member
	 * @return	void
	 */
	public function setTags( array|string $set, ?Member $member=NULL ): void
	{
		if( $member === NULL )
		{
			$member = Member::loggedIn();
		}

		/* Grab the current tags so that we can update those later */
		$currentTags = array( 'tags' => $this->tags(), 'prefix' => $this->prefix() );

		$aaiLookup = $this->tagAAIKey();
		$aapLookup = $this->tagAAPKey();
		$idColumn = static::$databaseColumnId;
		$this->tags = array( 'tags' => array(), 'prefix' => NULL );
		
		Db::i()->delete( 'core_tags', array( 'tag_aai_lookup=?', $aaiLookup ) );

		if ( !is_array( $set ) )
		{
			$set = array( $set );
		}

		/* Weed out duplicates */
		$set = array_unique( $set );

		$insert = [];
		foreach ( $set as $key => $tag )
		{
			/* Make sure we have a valid tag */
			try
			{
				Tag::load( $tag, 'tag_text' );
				$insert[] = array(
					'tag_aai_lookup'		=> $aaiLookup,
					'tag_aap_lookup'		=> $aapLookup,
					'tag_meta_app'			=> static::$application,
					'tag_meta_area'			=> static::$module,
					'tag_meta_id'			=> $this->$idColumn,
					'tag_meta_parent_id'	=> ( isset( static::$containerNodeClass ) ? $this->container()->_id : 0 ),
					'tag_member_id'			=> $member->member_id ?: 0,
					'tag_added'				=> time(),
					'tag_prefix'			=> $key == 'prefix',
					'tag_text'				=> $tag
				);

				if ( $key == 'prefix' )
				{
					$this->tags['prefix'] = $tag;
				}
				else
				{
					$this->tags['tags'][] = $tag;
				}
			}
			catch( \OutOfRangeException ) {}
		}

		if( count( $insert ) )
		{
			Db::i()->insert( 'core_tags', $insert, true );
		}
					
		Db::i()->insert( 'core_tags_cache', array(
			'tag_cache_key'		=> $aaiLookup,
			'tag_cache_text'	=> json_encode( array( 'tags' => $this->tags['tags'], 'prefix' => $this->tags['prefix'] ) ),
			'tag_cache_date'	=> time()
		), TRUE );

		if( isset( static::$containerNodeClass ) )
		{
			$containerClass = static::$containerNodeClass;
			if ( isset( $containerClass::$permissionMap['read'] ) )
			{
				$permissions = $containerClass::load( $this->container()->_id )->permissions();

				if ( isset( $permissions[ 'perm_' . $containerClass::$permissionMap['read'] ] ) )
				{
					Db::i()->insert( 'core_tags_perms', array(
						'tag_perm_aai_lookup'		=> $aaiLookup,
						'tag_perm_aap_lookup'		=> $aapLookup,
						'tag_perm_text'				=> $permissions[ 'perm_' . $containerClass::$permissionMap['read'] ],
						'tag_perm_visible'			=> ( $this->hidden() OR ( IPS::classUsesTrait( $this, 'FuturePublishing' ) AND $this->isFutureDate() ) ) ? 0 : 1,
					), TRUE );
				}
			}
		}

		/* Update the tag totals for the new and old tags */
		Tag::updateTagTotals( $this, $currentTags );

		/* Callback once tags are updated */
		$this->processAfterTagUpdate();
	}
	
	/**
	 * Get tag AAI key
	 *
	 * @return	string
	 */
	public function tagAAIKey(): string
	{
		$idColumn = static::$databaseColumnId;
		return md5( static::$application . ';' . static::$module . ';' . $this->$idColumn );
	}
	
	/**
	 * Get tag AAP key
	 *
	 * @return	string
	 */
	public function tagAAPKey(): string
	{
		if( isset( static::$containerNodeClass ) )
		{
			$containerClass = static::$containerNodeClass;
			return md5( $containerClass::$permApp . ';' . $containerClass::$permType . ';' . $this->container()->_id );
		}

		return md5( static::$application . ';' . static::$module );
	}
	
	/**
	 * Generate the tags form element
	 *
	 * @note	It is up to the calling code to verify the tag input field should be shown
	 * @param Item|null $item		Item, if editing
	 * @param string|Model|null $container	Container
	 * @param bool $minimized	If the form field should be minimized by default
	 * @param bool $isOnlyForRemoval	If TRUE, the field is only being shown to allow removal of existing tags, this will skip the validation
	 * @return	Text|NULL
	 */
	public static function tagsFormField( ?Item $item, Model|string|null $container = null, bool $minimized = false, bool $isOnlyForRemoval = false ): Text|NULL
	{
		$options = array(
			'autocomplete' => array(
				'unique' => TRUE,
				'source' => static::definedTags(),
				'resultItemTemplate' => 'core.autocomplete.tagsResultItem',
				'freeChoice' => false,
				'minimized' => $minimized,
				'addTokenText' => Member::loggedIn()->language()->get( 'tags_optional' ),
				'alphabetical' => false
			) );

		if ( Settings::i()->tags_force_lower )
		{
			$options['autocomplete']['forceLower'] = TRUE;
		}
		if ( Settings::i()->tags_min )
		{
			$options['autocomplete']['minItems'] = Settings::i()->tags_min;
		}
		if ( Settings::i()->tags_max and !$isOnlyForRemoval )
		{
			$options['autocomplete']['maxItems'] = Settings::i()->tags_max;
		}

		$options['autocomplete']['prefix'] = static::canPrefix( NULL, $container );
		$options['autocomplete']['disallowedCharacters'] = array( '#' ); // @todo Pending \IPS\Http\Url rework, hashes cannot be used in URLs
					
		if ( count( $options['autocomplete']['source'] ) )
		{
			$containerClass = static::$containerNodeClass ?? null;
			$containerFieldName = static::$formLangPrefix . 'container';
			$thisClass = get_called_class();
			return new Text( static::$formLangPrefix . 'tags', $item ? ( $item->prefix() ? array_merge( array( 'prefix' => $item->prefix() ), $item->tags() ) : $item->tags() ) : array(), ( Settings::i()->tags_min and Settings::i()->tags_min_req ) ? ( $container ? TRUE : NULL ) : FALSE, $options, function ( $val ) use ( $container, $containerClass, $containerFieldName, $thisClass ) {
				if ( empty( $val ) and Settings::i()->tags_min and Settings::i()->tags_min_req )
				{
					if ( !$container )
					{
						if( $containerClass !== null )
						{
							try
							{
								$container = $containerClass::load( Request::i()->$containerFieldName );
							}
							catch ( OutOfRangeException )
							{
								return TRUE;
							}
						}

						/* @var Item $thisClass */
						if ( $thisClass::canTag( NULL, $container ) )
						{
							throw new DomainException('form_required');
						}
					}
				}
				return TRUE;
			} );
		}

		return NULL;
	}
	
	/**
	 * Callback to execute when tags are edited
	 *
	 * @return	void
	 */
	public function processAfterTagUpdate(): void
	{
		if( IPS::classUsesTrait( $this, 'IPS\Content\ItemTopic' ) )
		{
			$this->itemTagsUpdated();
		}
	}
}