<?php

/**
 * @brief        Tag
 * @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
 * @since        3/29/2024
 */

namespace IPS\Content;

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

use BadMethodCallException;
use Exception;
use InvalidArgumentException;
use IPS\Application;
use IPS\Content;
use IPS\Data\Store;
use IPS\DateTime;
use IPS\Db;
use IPS\File;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\Translatable;
use IPS\Helpers\Form\Upload;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\IPS;
use IPS\Lang;
use IPS\Member;
use IPS\Node\Model;
use IPS\Request;
use IPS\Task;
use IPS\Theme;
use OutOfRangeException;
use UnderflowException;
use function get_class;
use function in_array;
use function is_array;
use function count;

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

class Tag extends Model
{
	use Followable;

	/**
	 * @brief	Database Table
	 */
	public static ?string $databaseTable = 'core_tags_data';

	/**
	 * @brief	Database Prefix
	 */
	public static string $databasePrefix = 'tag_';

	/**
	 * @brief	Multiton Store
	 */
	protected static array $multitons;

	/**
	 * @brief	[ActiveRecord] Multiton Map
	 */
	protected static array $multitonMap	= array();

	/**
	 * @brief	[ActiveRecord] Database ID Fields
	 * @note	If using this, declare a static $multitonMap = array(); in the child class to prevent duplicate loading queries
	 */
	protected static array $databaseIdFields = array( 'tag_text', 'tag_text_seo' );

	/**
	 * @brief	[Node] Node Title
	 */
	public static string $nodeTitle = 'tags';

	/**
	 * @brief	[Node] Title prefix.  If specified, will look for a language key with "{$key}_title" as the key
	 */
	public static ?string $titleLangPrefix = 'tags_tag_';

	/**
	 * @brief	[Node] Description suffix.  If specified, will look for a language key with "{$titleLangPrefix}_{$id}_{$descriptionLangSuffix}" as the key
	 */
	public static ?string $descriptionLangSuffix = '_desc';

	/**
	 * @brief	[Node] Enabled/Disabled Column
	 */
	public static ?string $databaseColumnEnabledDisabled = 'enabled';

	/**
	 * @brief	[Node] Order Database Column
	 */
	public static ?string $databaseColumnOrder = 'text_seo';

	/**
	 * @brief	Cover Photo Storage Extension
	 */
	public static string $coverPhotoStorageExtension = 'core_Tags';

	/**
	 * @brief	Use a default cover photo
	 */
	public static bool $coverPhotoDefault = true;

	/**
	 * @brief	Database Column Map
	 */
	public static array $databaseColumnMap = array(
		'cover_photo'			=> 'cover_photo',
		'cover_photo_offset'	=> 'cover_offset'
	);

	/**
	 * @brief	Application
	 */
	public static string $application = 'core';

	/**
	 * @brief	[Node] Sortable?
	 */
	public static bool $nodeSortable = false;

	/**
	 * [Node] Return the custom badge for each row
	 *
	 * @return	NULL|array		Null for no badge, or an array of badge data (0 => CSS class type, 1 => language string, 2 => optional raw HTML to show instead of language string)
	 */
	protected function get__badge(): ?array
	{
		if( $this->mergeInProgress() )
		{
			return [ 0 => 'ipsBadge ipsBadge--intermediary', 1 => null, 2 => Member::loggedIn()->language()->addToStack( 'tag_merge_processing' ) ];
		}

		$totalItems = array_sum( $this->totals );
		$text = Member::loggedIn()->language()->addToStack( 'tag_total_items', true, [ 'pluralize' => [ $totalItems ] ] );
		Member::loggedIn()->language()->parseOutputForDisplay( $text );

		return [
			0 => ( $totalItems ? 'ipsBadge ipsBadge--positive' : 'ipsBadge ipsBadge--neutral' ),
			2 => $text
		];
	}

	/**
	 * [Node] Get whether or not this node is enabled
	 *
	 * @note	Return value NULL indicates the node cannot be enabled/disabled
	 * @return	bool|null
	 */
	protected function get__enabled(): ?bool
	{
		if( $this->mergeInProgress() )
		{
			return null;
		}

		return parent::get__enabled();
	}

	/**
	 * [Node] Get Title
	 *
	 * @return	string
	 */
	protected function get__title(): string
	{
		if( Member::loggedIn()->language()->checkKeyExists( static::$titleLangPrefix . $this->_id ) )
		{
			return Member::loggedIn()->language()->addToStack( static::$titleLangPrefix . $this->_id, NULL, array( 'escape' => TRUE ) );
		}

		return '';
	}

	/**
	 * [Node] Get content table description
	 *
	 * @return	string|null
	 */
	protected function get_description(): ?string
	{
		if( Member::loggedIn()->language()->checkKeyExists( static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix ) )
		{
			return Member::loggedIn()->language()->addToStack( static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix );
		}
		return NULL;
	}

	/**
	 * [Node] Get Icon for tree
	 *
	 * @note	Return the class for the icon (e.g. 'globe', the 'fa fa-' is added automatically so you do not need this here)
	 * @return	mixed
	 */
	protected function get__icon(): mixed
	{
		return $this->mergeInProgress() ? 'lock' : null;
	}

	/**
	 * @return array
	 */
	public function get_totals() : array
	{
		return isset( $this->_data['totals'] ) ? json_decode( $this->_data['totals'], true ) : array();
	}

	/**
	 * @param array|null $val
	 * @return void
	 */
	public function set_totals( ?array $val ) : void
	{
		$this->_data['totals'] = ( is_array( $val ) and count( $val ) ) ? json_encode( $val ) : null;
	}

    /**
     * [Node] Get content table meta description
     *
     * @return	string|null
     */
    public function metaDescription(): ?string
    {
        if( !Member::loggedIn()->language()->checkKeyExists( static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix ) )
        {
            return null;
        }

        return parent::metaDescription();
    }

	/**
	 * Is this node currently queued for deleting or moving content OR is it the target of content queued to be moved from another node?
	 *
	 * @return	bool
	 */
	public function deleteOrMoveQueued(): bool
	{
		if( $this->mergeInProgress() )
		{
			return true;
		}

		return parent::deleteOrMoveQueued();
	}

    /**
     * [Node] Get the title to store in the log
     *
     * @return	string|null
     */
    public function titleForLog(): ?string
    {
        return $this->text;
    }

	/**
	 * Determines if the tag has been flagged for merging.
	 * We need to flag it because this can take a while if there
	 * are a lot of items, and we don't want anyone messing with it
	 *
	 * @return bool
	 */
	public function mergeInProgress() : bool
	{
		return isset( $this->totals['merge'] );
	}

	/**
	 * Form
	 *
	 * @param	Form	$form	The form
	 * @return	void
	 */
	public function form( Form &$form ): void
	{
		$form->add( new Text( 'tag_text', $this->text, true, [
            'regex' => '/^[^,]+$/i',
        ], function( $val ){
			try
			{
				Db::i()->select( '*', static::$databaseTable, [ 'tag_text=? and tag_id <> ?', $val, (int) $this->id ] )->first();
				throw new InvalidArgumentException( 'err__tag_duplicate' );
			}
			catch( UnderflowException ){}
		} ) );

		$form->add( new Text( 'tag_text_seo', $this->text_seo, false, array(), function( $val ){
			if( empty( $val ) )
			{
				$val = Friendly::seoTitle( Request::i()->tag_text );
			}

			try
			{
				Db::i()->select( '*', static::$databaseTable, [ 'tag_text_seo=? and tag_id <> ?', $val, (int) $this->id ] )->first();
				throw new InvalidArgumentException( 'err__tag_duplicate' );
			}
			catch( UnderflowException ){}
		} ) );

		$form->add( new Translatable( 'tag_headline', null, false, [
			'app' => 'core',
			'key' => ( $this->id ? static::$titleLangPrefix . $this->id : null )
		] ) );

		$form->add( new Translatable( 'tag_description', null, false, [
			'app' => 'core',
			'key' => ( $this->id ? static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix : null ),
			'textArea' => [
				'rows' => 5
			]
		] ) );

		$form->add( new Upload( 'tag_cover_photo', $this->coverPhotoFile(), false, [
			'storageExtension' => 'core_Tags',
			'image' => true,
			'multiple' => false,
			'allowStockPhotos' => true
		] ) );

		$form->add( new YesNo( 'tag_recommended', $this->recommended, false ) );
	}

	/**
	 * [Node] Format form values from add/edit form for save
	 *
	 * @param	array	$values	Values from the form
	 * @return	array
	 */
	public function formatFormValues( array $values ): array
	{
		if( !$this->id )
		{
			/* Enable by default */
			$values['tag_enabled'] = true;

			$this->text = $values['tag_text'];
			$this->save();
		}

		/* Do we need to update the tag name? */
		if( $this->text != $values['tag_text'] )
		{
			Task::queue( 'core', 'UpdateTaggedItems', [ 'tag' => $this->text, 'new' => $values['tag_text'] ], 5, [ 'tag' ] );
		}

		$values['tag_text_seo'] = $values['tag_text_seo'] ?: Friendly::seoTitle( $values['tag_text'] );
		$values['tag_cover_photo'] = $values['tag_cover_photo'] instanceof File ? (string) $values['tag_cover_photo'] : null;

		Lang::saveCustom( 'core', static::$titleLangPrefix . $this->id, $values['tag_headline'] );
		Lang::saveCustom( 'core', static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix, $values['tag_description'] );
		unset( $values['tag_headline'] );
		unset( $values['tag_description'] );

		return $values;
	}

	/**
	 * [Node] Get buttons to display in tree
	 * Example code explains return value
	 *
	 * @code
	 * array(
	 * array(
	 * 'icon'	=>	'plus-circle', // Name of FontAwesome icon to use
	 * 'title'	=> 'foo',		// Language key to use for button's title parameter
	 * 'link'	=> \IPS\Http\Url::internal( 'app=foo...' )	// URI to link to
	 * 'class'	=> 'modalLink'	// CSS Class to use on link (Optional)
	 * ),
	 * ...							// Additional buttons
	 * );
	 * @endcode
	 * @param Url $url		Base URL
	 * @param	bool	$subnode	Is this a subnode?
	 * @return	array
	 */
	public function getButtons( Url $url, bool $subnode=FALSE ): array
	{
		$buttons = [];

		if( $this->recommended )
		{
			$buttons['recommend'] = [
				'icon' => 'star',
				'title' => 'tag_mark_norecommend',
				'link' => $url->setQueryString( [ 'do' => 'recommend', 'id' => $this->id ] )->csrf()
			];
		}
		else
		{
			$buttons['recommend'] = [
				'icon' => 'regular fa-star',
				'title' => 'tag_mark_recommend',
				'link' => $url->setQueryString( [ 'do' => 'recommend', 'id' => $this->id ] )->csrf()
			];
		}

        $buttons['view'] = [
            'icon' => 'search',
            'title' => 'tag_view_page',
            'link' => $this->url(),
            'target' => '_blank'
        ];

		return array_merge( $buttons, parent::getButtons( $url, $subnode ) );
	}

	/**
	 * Recount the totals for this content type all affected tags
	 *
	 * @param Item $item
	 * @param array $oldTags
	 * @return void
	 */
	public static function updateTagTotals( Item $item, array $oldTags ) : void
	{
		if( !IPS::classUsesTrait( $item, Taggable::class ) )
		{
			return;
		}

		$newTags = array_diff( $item->tags(), $oldTags['tags'] );
		$removedTags = array_diff( $oldTags['tags'], $item->tags() );
		$tagsToUpdate = array_merge( $newTags, $removedTags );

		if( $item->prefix() != $oldTags['prefix'] )
		{
			if( $oldTags['prefix'] and !in_array( $oldTags['prefix'], $tagsToUpdate ) )
			{
				$tagsToUpdate[] = $oldTags['prefix'];
			}

			if( $prefix = $item->prefix() )
			{
				if( !in_array( $prefix, $tagsToUpdate ) )
				{
					$tagsToUpdate[] = $prefix;
				}
			}
		}

		foreach( $tagsToUpdate as $tag )
		{
			try
			{
				static::load( $tag, 'tag_text' )->recount();
			}
			catch( OutOfRangeException ){}
		}

		/* Clean-up anything pinned in removed tags */
		if( count( $removedTags ) )
		{
			foreach( $removedTags as $tag )
			{
				try
				{
					static::load( $tag, 'tag_text' )->removePinnedItem( $item );
				}
				catch( OutOfRangeException ){}
			}
		}
	}

	/**
	 * Rebuild the statistics for the tag
	 *
	 * @return void
	 */
	public function recount() : void
	{
		/* If we're in middle of a merge, do nothing */
		if( $this->mergeInProgress() )
		{
			return;
		}

		$totals = [];
		$this->last_used = null;
		foreach( Db::i()->select( 'tag_meta_app, tag_meta_area, count(tag_meta_id) as total, max(tag_added) as tag_added', 'core_tags', [ 'tag_text=?', $this->text ], null, null, [ 'tag_meta_app', 'tag_meta_area' ] ) as $row )
		{
			$type = $row['tag_meta_app'] . '_' . $row['tag_meta_area'];
			$totals[ $type ] = $row['total'];

			if( $this->last_used === null or $this->last_used < $row['tag_added'] )
			{
				$this->last_used = $row['tag_added'];
			}
		}

		$this->totals = $totals;
		$this->save();
	}

	/**
	 * Return all enabled tags as SystemTag objects
	 *
	 * @return array<Tag>
	 */
	public static function allEnabledTagsAsObjects() : array
	{
		$tags = [];
		foreach( Db::i()->select( '*', static::$databaseTable, [ 'tag_enabled=?', 1 ] ) as $tag )
		{
			$tag = static::constructFromData( $tag );
			$tags[ $tag->text_seo ] = $tag;
		}

		return $tags;
	}
	/**
	 * Return all tags, even if disabled.
	 * Primarily used for the search screen.
	 *
	 * @return array
	 */
	public static function allTags() : array
	{
		return iterator_to_array(
			Db::i()->select( 'tag_text', static::$databaseTable, null, 'tag_recommended desc, tag_text' )
		);
	}

	/**
	 * Return all active content types that use tags
	 *
	 * @return array
	 */
	public static function getTaggableContentTypes() : array
	{
		$return = [];
		foreach( Content::routedClasses( Member::loggedIn(), false, true ) as $class )
		{
			if( IPS::classUsesTrait( $class, Taggable::class ) )
			{
				$bits = explode( "\\", $class );
				$tab = $bits[1] . '__' . array_pop( $bits );
				$return[ $tab ] = $class;
				Member::loggedIn()->language()->words[ $tab . '_pl' ] = Member::loggedIn()->language()->get( $class::$title . '_pl' );
			}
		}
		return $return;
	}

	/**
	 * Max amount of pinned items that will show on a tag page
	 * because sometimes people can be ridiculous
	 */
	const TAG_PINNED_LIMIT = 20;

	/**
	 * @var array|null
	 */
	protected ?array $_pinnedItems = null;

	/**
	 * Return the content item pinned to the top of the tag page
	 *
	 * @param int|null $limit
	 * @return array|null
	 */
	public function getPinnedItems( ?int $limit=null ) : ?array
	{
		if( $this->_pinnedItems === null )
		{
			$items = [];
			foreach( Db::i()->select( '*', 'core_tags_pinned', [ 'pinned_tag_id=?', $this->id ], 'rand()', ( $limit ?? static::TAG_PINNED_LIMIT ) ) as $row )
			{
				$itemClass = $row['pinned_item_class'];
				if( !Application::appIsEnabled( $itemClass::$application ) )
				{
					continue;
				}

				try
				{
					$item = $itemClass::load( $row['pinned_item_id'] );
					if( $item->canView() )
					{
						$item->pinnedData = $row;
						$items[] = $item;
					}
				}
				catch( OutOfRangeException ){}
			}

			$this->_pinnedItems = $items;
		}

		return count( $this->_pinnedItems ) ? $this->_pinnedItems : null;
	}

	/**
	 * Pin an item to the top of the tag page
	 *
	 * @param Item $item
	 * @param DateTime|null $endDate
	 * @param File|null $image
	 * @return void
	 */
	public function pinItem( Item $item, ?DateTime $endDate=null, ?File $image=null ) : void
	{
		$idColumn = $item::$databaseColumnId;

		try
		{
			Db::i()->select( '*', 'core_tags_pinned', [ 'pinned_tag_id=? and pinned_item_class=? and pinned_item_id=?', $this->id, get_class( $item ), $item->$idColumn ] )->first();

		}
		catch( UnderflowException )
		{
			Db::i()->insert( 'core_tags_pinned',[
				'pinned_tag_id' => $this->id,
				'pinned_item_class' => get_class( $item ),
				'pinned_item_id' => $item->$idColumn,
				'pinned_date' => time(),
				'pinned_end_date' => $endDate?->getTimestamp(),
				'pinned_image' => $image ? (string) $image : ''
			] );
		}
	}

	/**
	 * Remove pinned item from the top of the tag page
	 *
	 * @param Item $item
	 * @return void
	 */
	public function removePinnedItem( Item $item ) : void
	{
		$idColumn = $item::$databaseColumnId;

		/* Delete the image, if there is one */
		try
		{
			$image = Db::i()->select( 'pinned_image', 'core_tags_pinned', [ 'pinned_tag_id=? and pinned_item_class=? and pinned_item_id=?', $this->id, get_class( $item ), $item->$idColumn ] )->first();
			if( $image )
			{
				File::get( 'core_Tags', $image )->delete();
			}
		}
		catch( UnderflowException ){}

		Db::i()->delete( 'core_tags_pinned', [ 'pinned_tag_id=? and pinned_item_class=? and pinned_item_id=?', $this->id, get_class( $item ), $item->$idColumn ] );
	}

	/**
	 * Use the data store to find the correct slug for the tag
	 *
	 * @param string $tagText
	 * @return Url
	 */
	public static function buildTagUrl( string $tagText ) : Url
	{
		if( !isset( Store::i()->tagSlugs ) )
		{
			Store::i()->tagSlugs = iterator_to_array(
				Db::i()->select( 'LOWER(tag_text) as tag_text,tag_text_seo', static::$databaseTable, [ 'tag_enabled=?', 1 ] )
					->setKeyField( 'tag_text' )
					->setValueField( 'tag_text_seo' )
			);
		}

		$cache = Store::i()->tagSlugs;
		$slug = $cache[ mb_strtolower( $tagText ) ] ?? Friendly::seoTitle( $tagText );
		return Url::internal( "app=core&module=discover&controller=tag&tag=" . $slug, "front", "tags" );
	}

	/**
	 * Get URL
	 *
	 * @return    Url|string|null
	 * @throws	BadMethodCallException
	 */
	public function url(): Url|string|null
	{
		return Url::internal( "app=core&module=discover&controller=tag&tag=" . $this->text_seo, "front", "tags" );
	}

	/**
	 * Cover Photo
	 *
	 * @return	mixed
	 */
	public function coverPhoto(): mixed
	{
		$photo = parent::coverPhoto();
		$photo->editable = Member::loggedIn()->modPermission( 'can_edit_tags' );
		$photo->overlay = Theme::i()->getTemplate( 'tags', 'core', 'front' )->coverPhotoOverlay( $this );
		return $photo;
	}

	/**
	 * Use the cache to build a list of tag IDs used in this item
	 *
	 * @param Item $item
	 * @return array
	 */
	public static function getTagIdsForItem( Item $item ) : array
	{
		$ids = [];
		foreach( $item->tags() as $tag )
		{
			if( $id = array_search( $tag, static::getStore() ) )
			{
				$ids[] = $id;
			}
		}

		if( $prefix = $item->prefix() )
		{
			if( $id = array_search( $prefix, static::getStore() ) )
			{
				$ids[] = $id;
			}
		}

		return $ids;
	}

	/**
	 * Tag Store
	 *
	 * @return	array
	 */
	public static function getStore(): array
	{
		if ( !isset( Store::i()->tags ) )
		{
			/* Cache only the enabled tags */
			$recommended = iterator_to_array(
				Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=? and tag_recommended=?', 1, 1 ], 'tag_text' )
					->setKeyField( 'tag_id' )
					->setValueField( 'tag_text' )
			);
			natcasesort( $recommended );

			$other = iterator_to_array(
				Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=? and tag_recommended=?', 1, 0 ], 'tag_text' )
					->setKeyField( 'tag_id' )
					->setValueField( 'tag_text' )
			);
			natcasesort( $other );

			Store::i()->tags = $recommended + $other;
		}

		return Store::i()->tags;
	}

	/**
	 * @brief	[ActiveRecord] Caches
	 * @note	Defined cache keys will be cleared automatically as needed
	 */
	protected array $caches = array( 'tags', 'tagSlugs' );

	/**
	 * [ActiveRecord] Duplicate
	 *
	 * @return	void
	 */
	public function __clone(): void
	{
		if ( $this->skipCloneDuplication === TRUE )
		{
			return;
		}

		$oldPhoto = $this->coverPhotoFile();

		$this->text = sprintf( Member::loggedIn()->language()->get( 'copy_tag_text' ), $this->text );
		$this->text_seo = Friendly::seoTitle( $this->text );
		$this->totals = null;

		parent::__clone();

		if( $oldPhoto )
		{
			try
			{
				$newPhoto = File::create( 'core_Tags', $oldPhoto->originalFilename, $oldPhoto->contents() );
				$this->cover_photo = (string) $newPhoto;
			}
			catch( Exception )
			{
				$this->cover_photo = null;
				$this->cover_offset = 0;
			}
		}

		$this->save();
	}

	/**
	 * Delete Record
	 *
	 * @return    void
	 */
	public function delete(): void
	{
		parent::delete();

		if( $photo = $this->coverPhotoFile() )
		{
			$photo->delete();
		}

		/* Delete any navigation items that reference this tag */
		foreach( Db::i()->select( '*', 'core_menu', [ 'app=? and extension=?', 'core', 'Tags' ] ) as $row )
		{
			if( $row['config'] and $config = json_decode( $row['config'], true ) )
			{
				if( $config['id'] == $this->id )
				{
					Db::i()->delete( 'core_menu', [ 'id=?', $row['id'] ] );
				}
			}
		}

		try
		{
			unset( Store::i()->frontNavigation );
		}
		catch( OutOfRangeException ){}

		/* Queue the background task to remove the tag from all currently tagged items */
		Task::queue( 'core', 'UpdateTaggedItems', [ 'tag' => $this->text, 'delete' => true ], 5, [ 'tag' ] );
	}

	/**
	 * 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	string		tag			Name
	 * @apiresponse	string		text_seo			URL Slug
	 * @apiresponse	string		headline			Headline
	 * @apiresponse	string		description		description
	 * @apiresponse	string		cover_photo		Cover Photo Path
	 * @apiresponse	int			recommended 	Recommended
	 */
	public function apiOutput( Member $authorizedMember = NULL ): array
	{
		$return = [
		'id'          => $this->id,
		'tag'        => $this->_title,
		'text-seo'        => $this->text_seo,
		'headline' => $this->headline,
		'url'         => (string) $this->url(),
		'description' => $this->description,
		];

		if( $file = $this->coverPhotoFile() )
		{
			$return[ 'cover_photo' ] = (string) $file;
		}else
		{
			$return[ 'cover_photo' ] = null;
		}
		$return['recommended'] = $this->recommended;

return $return;
	}
}