<?php
/**
 * @brief		Group 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		13 Mar 2013
 */

namespace IPS\Member;

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

use ArrayIterator;
use DateInterval;
use InvalidArgumentException;
use IPS\Application;
use IPS\Application\Module;
use IPS\Content\Search\Index;
use IPS\core\StaffDirectory\User;
use IPS\Data\Store;
use IPS\DateTime;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Lang;
use IPS\Member;
use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Settings;
use IPS\Widget;
use OutOfRangeException;
use function count;
use function defined;
use function in_array;
use function is_array;
use function str_replace;

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

/**
 * Group Model
 */
class Group extends ActiveRecord
{
	/**
	 * @brief	[ActiveRecord] Multiton Store
	 */
	protected static array $multitons;

	/**
	 * @brief	[ActiveRecord] Database Table
	 */
	public static ?string $databaseTable = 'core_groups';
		
	/**
	 * @brief	[ActiveRecord] ID Database Column
	 */
	public static string $databaseColumnId = 'g_id';
	
	/**
	 * @brief	Bitwise keys
	 */
	protected static array $bitOptions = array(
		'g_bitoptions'	=> array(
			'g_bitoptions'	=> array(
				'gbw_mod_post_unit_type'	=> 1, 			// Lift moderation after x. 1 is days, 0 is posts. Corresponds to g_mod_post_unit
				'gbw_ppd_unit_type'			=> 2, 			// Lift post-per-day limit after x. 1 is days, 0 is posts. Corresponds to g_ppd_unit
				'gbw_displayname_unit_type'	=> 4, 			// Username change restrictions. 1 is days, 0 is posts. Corresponds to g_displayname_unit
				'gbw_sig_unit_type'			=> 8, 			// Signature edit restrictions. 1 is days, 0 is posts. Corresponds to g_sig_unit
				'gbw_promote_unit_type'		=> 16, 			// 16 is deprecated (previously gbw_promote_unit_type). Remove in 4.1.19.
				// 32 is deprecated (previously gbw_no_status_update)
				// 64 is deprecated (previously gbw_soft_delete)
				// 128 is deprecated (previously gbw_soft_delete_own)
				// 256 is deprecated (previously gbw_soft_delete_own_topic)
				// 512 is deprecated (previously gbw_un_soft_delete)
				// 1024 is deprecated (previously gbw_soft_delete_see)
				// 2048 is deprecated (previously gbw_soft_delete_topic)
				// 4096 is deprecated (previously gbw_un_soft_delete_topic)
				// 8192 is deprecated (previously gbw_soft_delete_topic_see)
				// 16384 is deprecated (previously gbw_soft_delete_reason)
				// 32768 is deprecated (previously gbw_soft_delete_see_post)
				// 65536 is deprecated (previously gbw_allow_customization)
				// 131072 is deprecated (previously gbw_allow_url_bgimage)
				'gbw_allow_upload_bgimage'	=> 262144, 		// Can upload a cover photo?
				'gbw_view_reps'				=> 524288, 		// Can view who gave reputation?
				'gbw_no_status_import'		=> 1048576, 	// Can NOT import status updates from Facebook/Twitter
				'gbw_disable_tagging'		=> 2097152, 	// Can NOT use tags
				'gbw_disable_prefixes'		=> 4194304, 	// Can NOT use prefixes
				// 8388608 is deprecated (previously gbw_view_last_info)
				// 16777216 is deprecated (previously gbw_view_online_lists)
				// 33554432 is deprecated (previously gbw_hide_leaders_page)
				'gbw_pm_unblockable'		=> 67108864,	// Deprecated in favour of global unblockable setting
				'gbw_pm_override_inbox_full'=> 134217728,	// 1 means this group can send other members PMs even when that member's inbox is full
				// 268435456 is deprecated (previously gbw_no_report)
				'gbw_cannot_be_ignored'		=> 536870912,	// 1 means they cannot be ignored. 0 means they can
				'gbw_delete_attachments'	=> 1073741824,	// 1 means they can delete attachments from the "My Attachments" screen
			),
			'g_bitoptions2'	=> array(
				'gbw_post_highlight'	=> 1,	// 1 means on, 0 means off
				'gbw_hide_group'		=> 2,	// Hide this group on the front end in search and similar areas: 1 is on, 0 is off
				// 4 is deprecated (previously gbw_lock_unlock_own)
				// 8 is deprecated (gbw_promote)
				'gbw_immune_auto_mod'   => 16,	// Users in this group can not be auto moderated
				'gbw_paid_clubs'		=> 32,	// Users in this group can create paid clubs
				'gbw_can_post_anonymously'	=> 64, // Users in this group can post anonymously (where allowed)
				'gbw_hide_inline_modevents'	=> 128, // Users in this group can see inline moderation events (where supported)
				'gbw_posted_in'				=> 256, // Items (topics, articles) will be marked as 'posted in by this group'
				'gbw_posted_in_secondary'	=> 512, // Items (topics, articles) will be marked as 'posted in by this group' when the group is set as a secondary group
				'gbw_club_manage_indexing'	=> 1024, // Users in this group can manage indexing of clubs
				'gbw_view_helpful'			=> 2048, // Users in this group can view who found content helpful
				'gbw_change_layouts'		=> 4096, // Users in this group can change the layouts of key areas set by themes
				'gbw_can_mark_helpful'		=> 8192, // Users in this group can mark content as helpful
			)
		)
	);
		
	/**
	 * @brief	[ActiveRecord] Attempt to load from cache
	 * @note	If this is set to TRUE you MUST define a getStore() method to return the objects from cache
	 */
	protected static bool $loadFromCache = TRUE;

	/**
	 * Group datastore
	 *
	 * @return	array
	 */
	public static function getStore(): array
	{
		if ( !isset( Store::i()->groups ) )
		{
			Store::i()->groups = iterator_to_array( Db::i()->select( '*', 'core_groups' )->setKeyField( 'g_id' ) );
		}

		return Store::i()->groups;
	}
	
	/**
	 * @brief	Stored Groups
	 */
	protected static ?array $allGroups = NULL;

	/**
	 * Get groups
	 *
	 * @param bool $showAdminGroups Show admin groups. Used to restrict admin groups from being available when you cannot add/edit members in them.
	 * @param bool $showGuestGroups Show guest groups. Used to remove the guest group from the available groups returned.
	 * @param bool $hideForFiltering If the group is set not to not be available to filter by, should we hide them?
	 * @return array|null
	 */
	public static function groups( bool $showAdminGroups=TRUE, bool $showGuestGroups=TRUE, bool $hideForFiltering=FALSE ): ?array
	{
		if ( !static::$allGroups )
		{
			static::$allGroups = iterator_to_array( new ActiveRecordIterator( new ArrayIterator( static::getStore() ), 'IPS\Member\Group' ) );

			/* If we have a dispatcher instance, sort the groups alphabetically */
			if( Dispatcher::hasInstance() )
			{
				/* Get all of the group names in one query */
				$sortedGroupIds = array();

				foreach( Db::i()->select( '*', 'core_sys_lang_words', array( 'word_key LIKE ? AND lang_id=?', 'core_group_%', Member::loggedIn()->language()->id ), 'word_custom ASC' ) as $lang )
				{
					$sortedGroupIds[] = str_replace( 'core_group_', '', $lang['word_key'] );
				}

				$finalGroups = array();

				foreach( $sortedGroupIds as $groupId )
				{
					if( isset( static::$allGroups[ $groupId ] ) )
					{
						$finalGroups[ $groupId ] = static::$allGroups[ $groupId ];
					}
				}

				static::$allGroups = $finalGroups;
			}
		}

		$groups = static::$allGroups;

		if ( !$showGuestGroups )
		{
			unset( $groups[ Settings::i()->guest_group ] );
		}

		if( !$showAdminGroups )
		{
			$administrators = Member::administrators();
			foreach( $groups as $k => $_group )
			{
				if ( isset( $administrators['g'][ $_group->g_id ] ) )
				{
					unset( $groups[ $k ] );
				}
			}
		}

		if( $hideForFiltering )
		{
			foreach( $groups as $groupId => $group )
			{
				if( $group->g_bitoptions['gbw_hide_group'] )
				{
					unset( $groups[ $groupId ] );
				}
			}
		}

		return $groups;
	}

	/**
	 * [ActiveRecord] Duplicate
	 *
	 * @return	void
	 */
	public function __clone()
	{
		$oldId = $this->g_id;
		$oldGroup = $this;
		parent::__clone();

		/* Rebuild permission indexes */
		$perms = array( 'perm_view', 'perm_2', 'perm_3', 'perm_4', 'perm_5', 'perm_6', 'perm_7' );
		$where = array();
		foreach( $perms as $key )
		{
			$where[] = Db::i()->findInSet( $key, array($oldId) );
		}

		foreach( Db::i()->select( '*', 'core_permission_index', implode( ' OR ', $where ) ) as $index )
		{
			foreach( $perms as $key )
			{
				$groups = explode( ",", $index[$key] );
				if( in_array( $oldId, $groups ) and !in_array( $this->g_id, $groups ) )
				{
					$groups[] = $this->g_id;
				}

				$index[$key] = implode( ",", $groups );
			}
			Db::i()->update( 'core_permission_index', $index, array( 'perm_id = ?', $index['perm_id'] ) );
		}

		$extensions = Application::allExtensions( 'core', 'GroupForm', FALSE, 'core', 'GroupSettings', TRUE );
		/* Process each extension */
		foreach ( $extensions as $class )
		{
			$class->cloneGroup( $oldGroup, $this );
		}

		Lang::saveCustom( 'core', "core_group_{$this->g_id}", iterator_to_array( Db::i()->select( '*', 'core_sys_lang_words', array( 'word_key=?', "core_group_{$oldId}" ) )->setKeyField('lang_id')->setValueField('word_custom') ) );

		/* Clear application and module store so that permissions are correctly rebuilt */
		unset( Store::i()->applications );
		unset( Store::i()->modules );
		unset( Store::i()->groups );

		/* Rebuild search index */
		Index::i()->rebuild();
	}
	
	/**
	 * Get data
	 *
	 * @return	array
	 */
	public function data(): array
	{
		return $this->_data;
	}
	
	/**
	 * Magic Method: To String
	 * Returns group name
	 *
	 * @return	string
	 */
	public function __toString()
	{
		return $this->name;
	}
	
	/**
	 * Get name
	 *
	 * @return	string
	 */
	public function get_name(): string
	{
		return Member::loggedIn()->language()->addToStack( "core_group_{$this->g_id}" );
	}
	
	/**
	 * Get formatted name
	 *
	 * @return	string
	 */
	public function get_formattedName(): string
	{
		return $this->formatName( $this->name );
	}

	/**
	 * Get number of members in this group
	 *
	 * @note	Includes count of members with this group as a secondary group
	 * @note	The count is cached for 10 minutes as the query can be resource intensive
	 * @return	int
	 */
	public function getCount(): int
	{
		$key = 'groupMembersCount_' . $this->g_id;

		try
		{
			$count = Store::i()->$key;

			if( $values = json_decode( $count, true ) )
			{
				if( $values['expire'] < time() )
				{
					throw new OutOfRangeException;
				}

				return $values['count'];
			}
			else
			{
				throw new OutOfRangeException;
			}
		}
		catch ( OutOfRangeException $e )
		{
			$count = Db::i()->select( 'COUNT(*)',  'core_members', array( 'member_group_id=? OR FIND_IN_SET( ?, mgroup_others )', $this->g_id, $this->g_id ) )->first();

			Store::i()->$key = json_encode( array( 'count' => $count, 'expire' => DateTime::create()->add( new DateInterval( 'PT10M' ) )->getTimestamp() ) );
		}

		return (int) $count;
	}
	
	/**
	 * Format Name
	 *
	 * @param	string	$name	The name to format
	 * @return	string
	 */
	public function formatName( string $name ): string
	{
		return ( $this->prefix . htmlspecialchars( $name, ENT_DISALLOWED | ENT_QUOTES, 'UTF-8', FALSE ) . $this->suffix );
	}

	/**
	 * Can use module
	 *
	 * @param	Module	$module	The module to test
	 * @return	bool
	 */
	public function canAccessModule( Module $module ): bool
	{
		return $module->can( 'view', $this );
	}

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

	/**
	 * [ActiveRecord] Delete Record
	 *
	 * @return    void
	 */
	public function delete(): void
	{
		if ( in_array( $this->g_id, array( Settings::i()->guest_group, Settings::i()->member_group, Settings::i()->admin_group ) ) )
		{
			throw new InvalidArgumentException;
		}

		/* remove group from mod & staff section */

		Db::i()->delete( 'core_leaders', array( 'leader_type=? AND leader_type_id = ?', 'g', $this->g_id ) );
		Db::i()->delete( 'core_moderators', array( 'type=? AND id = ?', 'g', $this->g_id ) );
		Db::i()->delete( 'core_admin_permission_rows', array( 'row_id=? and row_id_type=?', $this->g_id, 'group' ) );

		/* Make sure no other groups have this group ID set to promote to */
		foreach(static::groups() as $group )
		{
			$promote = explode( '&', $group->g_promotion );

			if( $promote[0] == $this->g_id )
			{
				$group->g_promotion = '-1&' . $promote[1];
				$group->save();
			}
		}

		$extensions = Application::allExtensions( 'core', 'GroupForm', FALSE, 'core', 'GroupSettings', TRUE );
		/* Process each extension */
		foreach ( $extensions as $class )
		{
			$class->delete( $this );
		}
		
		parent::delete();
		Lang::deleteCustom( 'core', 'core_group_' . $this->g_id );

		/* Update Core Leaders */
		User::updateEmptySetting();

		/* Deleted group, expire widget caches (online stats may try to load a group) */
		Widget::deleteCaches();
	}

	/**
	 * Can this group be deleted?
	 *
	 * @return bool
	 */
	public function canDelete() : bool
	{
		$extensions = Application::allExtensions( 'core', 'GroupForm', FALSE, 'core', 'GroupSettings', TRUE );

		foreach ( $extensions as $class )
		{
			if( !$class->canDelete( $this ) )
			{
				return FALSE;
			}
		}

		return TRUE;
	}

	/**
	 * Return group that will show the posted in indicator
	 *
	 * @param array	$primary	Primary Group IDs
	 * @param array	$secondary	Array of Secondary Group IDs
	 * @return array|FALSE
	 */
	public static function postedIn( array $primary, array $secondary ): array|bool
	{
		if ( !static::$allGroups )
		{
			static::groups();
		}
		$return = array();

		foreach( array( 'primary', 'secondary' ) as $type )
		{
			if ( is_array( $$type ) and count( $$type ) )
			{
				foreach ( $$type as $id )
				{
					if ( isset( static::$allGroups[$id] ) and static::$allGroups[$id]->g_bitoptions[ ( $type == 'primary' ? 'gbw_posted_in' : 'gbw_posted_in_secondary') ] )
					{
						$return[ $id ] = static::$allGroups[$id];
					}
				}
			}
		}

		return ( count( $return ) ? $return : FALSE );
	}
	
	/**
	 * 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		name			Name
	 * @apiresponse	string		formattedName	Name with formatting
	 */
	public function apiOutput( Member $authorizedMember = NULL ): array
	{
		return array(
			'id'				=> $this->g_id,
			'name'				=> $this->name,
			'formattedName'		=> $this->formattedName,
		);
	}

	/**
	 * Get the restriction level this group has. This will be an integer of 0, 1 or 2, which correspond to minimal, standard and advanced
	 *
	 * @param bool $comments
	 *
	 * @return int
	 */
	public function getEditorRestrictionLevel( bool $comments=false ) : int
	{
		$field = $comments ? "g_editor_comments_restriction" : "g_editor_restriction";
		if ( is_int( $this->$field ) )
		{
			return $this->$field;
		}

		$administrators = Member::administrators();
		if ( isset( $administrators['g'][$this->g_id] ) )
		{
			return 2; // the advanced editor
		}

		return $comments ? 0 : 1; // minimal for comments, standard otherwise
	}
}