<?php
/**
 * @brief		Club 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 Feb 2017
 */

namespace IPS\Member;

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

use DateInterval;
use DomainException;
use InvalidArgumentException;
use IPS\Api\Webhook;
use IPS\Application;
use IPS\Application\Module;
use IPS\cms\Categories;
use IPS\cms\Pages\Page;
use IPS\Content\ClubContainer;
use IPS\Content\Embeddable;
use IPS\Content\Followable;
use IPS\core\Approval;
use IPS\core\extensions\nexus\Item\ClubMembership;
use IPS\core\FrontNavigation;
use IPS\core\ProfileFields\Api\Field;
use IPS\DateTime;
use IPS\Db;
use IPS\Db\Exception;
use IPS\Db\Select;
use IPS\Dispatcher;
use IPS\Events\Event;
use IPS\Extensions\ClubAbstract;
use IPS\File;
use IPS\GeoLocation;
use IPS\Helpers\Badge;
use IPS\Helpers\Badge\Icon;
use IPS\Helpers\CoverPhoto;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Address;
use IPS\Helpers\Form\Editor;
use IPS\Helpers\Form\Radio;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\TextArea;
use IPS\Helpers\Form\Upload;
use IPS\Helpers\Form\YesNo;
use IPS\Helpers\Menu;
use IPS\Helpers\Menu\Buttons;
use IPS\Helpers\Menu\Link;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\IPS;
use IPS\Lang;
use IPS\Log;
use IPS\Math\Number;
use IPS\Member;
use IPS\Member\Club\CustomField;
use IPS\Member\Club\Page as ClubPage;
use IPS\Member\Club\Template;
use IPS\nexus\Customer;
use IPS\nexus\Invoice;
use IPS\nexus\Money;
use IPS\nexus\Purchase;
use IPS\nexus\Purchase\RenewalTerm;
use IPS\nexus\Tax;
use IPS\Node\Model;
use IPS\Notification;
use IPS\Output;
use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Request;
use IPS\Settings;
use IPS\Task;
use IPS\Theme;
use OutOfRangeException;
use OverflowException;
use UnderflowException;
use function array_key_exists;
use function class_exists;
use function count;
use function defined;
use function get_class;
use function in_array;
use function is_array;
use function is_object;

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

/**
 * Club Model
 */
class Club extends ActiveRecord implements Embeddable
{
	use Followable
    {
        Followable::unfollow as public _unfollow;
        Followable::follow as public _follow;
    }

	/**
	 * @brief	Club: public
	 */
	const TYPE_PUBLIC = 'public';

	/**
	 * @brief	Club: open
	 */
	const TYPE_OPEN = 'open';

	/**
	 * @brief	Club: closed
	 */
	const TYPE_CLOSED = 'closed';

	/**
	 * @brief	Club: private
	 */
	const TYPE_PRIVATE = 'private';

	/**
	 * @brief	Club: read-only
	 */
	const TYPE_READONLY = 'readonly';

	/**
	 * @brief	Status: member
	 */
	const STATUS_MEMBER = 'member';

	/**
	 * @brief	Status: invited
	 */
	const STATUS_INVITED = 'invited';

	/**
	 * @brief	Status: invited (bypassing payment)
	 */
	const STATUS_INVITED_BYPASSING_PAYMENT = 'invited_bypassing_payment';

	/**
	 * @brief	Status: requested
	 */
	const STATUS_REQUESTED = 'requested';

	/**
	 * @brief	Status: awaiting payment
	 */
	const STATUS_WAITING_PAYMENT = 'payment_pending';

	/**
	 * @brief	Status: expired
	 */
	const STATUS_EXPIRED = 'expired';

	/**
	 * @brief	Status: expired moderator
	 */
	const STATUS_EXPIRED_MODERATOR = 'expired_moderator';

	/**
	 * @brief	Status: declined
	 */
	const STATUS_DECLINED = 'declined';

	/**
	 * @brief	Status: banned
	 */
	const STATUS_BANNED = 'banned';

	/**
	 * @brief	Status: moderator
	 */
	const STATUS_MODERATOR = 'moderator';

	/**
	 * @brief	Status: leader
	 */
	const STATUS_LEADER = 'leader';

	/**
	 * @brief	Not actually a status, but used for member history
	 */
	const STATUS_LEFT = 'left';

	/**
	 * @breif 	Constants for node permissions values
	 */
	const NODE_PRIVATE = 0;
	const NODE_PUBLIC = 1;
	const NODE_MODERATORS = 2;
	
	/**
	 * @brief	[ActiveRecord] Multiton Store
	 */
	protected static array $multitons;
	
	/**
	 * @brief	[ActiveRecord] Database Table
	 */
	public static ?string $databaseTable = 'core_clubs';
	
	/**
	 * @brief	Use a default cover photo
	 */
	public static bool $coverPhotoDefault = true;

	public static string $title = 'clubs';
	
	/* !Fetch Clubs */
	
	/**
	 * Construct ActiveRecord from database row
	 *
	 * @param array $data							Row from database table
	 * @param bool $updateMultitonStoreIfExists	Replace current object in multiton store if it already exists there?
	 * @return    static
	 */
	public static function constructFromData( array $data, bool $updateMultitonStoreIfExists = TRUE ): static
	{
		$return = parent::constructFromData( $data, $updateMultitonStoreIfExists );
		
		if ( isset( $data['member_id'] ) and isset( $data['status'] ) )
		{
			$return->memberStatuses[ $data['member_id'] ] = $data['status'];
		}
		
		return $return;
	}
		
	/**
	 * Get all clubs a member can see
	 *
	 * @param	Member|null				$member		The member to base permission off or NULL for all clubs
	 * @param int|array|null $limit		Number to get
	 * @param string $sortOption	The sort option ('last_activity', 'members', 'content' or 'created')
	 * @param bool|array|Member $mineOnly	Limit to clubs a particular member has joined (TRUE to use the same value as $member). Can also provide an array as array( 'member' => \IPS\Member, 'statuses' => array( STATUS_MEMBER... ) ) to limit to certain member statuses
	 * @param array $filters	Custom field filters
	 * @param mixed|null $extraWhere	Additional WHERE clause
	 * @param bool $countOnly	Only return a count, instead of an iterator
     * @param array|null $location  Array with lat and lon
	 * @return	ActiveRecordIterator|array|int
	 */
	public static function clubs( ?Member $member, int|array|null $limit, string $sortOption, bool|array|Member $mineOnly=FALSE, array $filters=array(), mixed $extraWhere=NULL, bool $countOnly=FALSE, ?array $location=null ): ActiveRecordIterator|int|array
	{
		$where = array();
		$joins = array();
		
		/* Restrict to clubs we can see */
		if ( $member and !$member->modPermission('can_access_all_clubs') )
		{
			/* Exclude clubs which are pending approval, unless we are the owner */
			if ( Settings::i()->clubs_require_approval )
			{
				$where[] = array( '( approved=1 OR owner=? )', $member->member_id );
			}
			
			/* Specify our memberships */
			if ( $member->member_id )
			{
				$joins['membership'] = array( array( 'core_clubs_memberships', 'membership' ), array( 'membership.club_id=core_clubs.id AND membership.member_id=?', $member->member_id ) );
				$where[] = array( "( type<>? OR membership.status IN('" . static::STATUS_MEMBER .  "','" . static::STATUS_MODERATOR . "','" . static::STATUS_LEADER . "','" . static::STATUS_EXPIRED . "','" . static::STATUS_EXPIRED_MODERATOR . "') )", static::TYPE_PRIVATE );
			}
			else
			{
				$where[] = array( 'type<>?', static::TYPE_PRIVATE );
			}
		}
		
		/* Restrict to clubs we have joined */
		if ( $mineOnly )
		{
			if ( is_array( $mineOnly ) )
			{
				$statuses = $mineOnly['statuses'];
				$mineOnly = $mineOnly['member'];
			}
			else
			{			
				$mineOnly = ( $mineOnly === TRUE ) ? $member : $mineOnly;
				$statuses = array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER, static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR );
			}
			if ( !$mineOnly->member_id )
			{
				return array();
			}
			
			if ( $member and $mineOnly->member_id === $member->member_id and isset( $joins['membership'] ) )
			{
				$where[] = array( "membership.status IN('" . static::STATUS_MEMBER .  "','" . static::STATUS_MODERATOR . "','" . static::STATUS_LEADER . "','" . static::STATUS_EXPIRED . "','" . static::STATUS_EXPIRED_MODERATOR . "')" );
			}
			else
			{
				$joins['others_membership'] = array( array( 'core_clubs_memberships', 'others_membership' ), array( 'others_membership.club_id=core_clubs.id AND others_membership.member_id=?', $mineOnly->member_id ) );
				$where[] = array( "others_membership.status IN('" . static::STATUS_MEMBER .  "','" . static::STATUS_MODERATOR . "','" . static::STATUS_LEADER . "','" . static::STATUS_EXPIRED . "','" . static::STATUS_EXPIRED_MODERATOR . "')" );
			}
		}
		
		/* Other filters */
		if ( $filters )
		{
			$joins['core_clubs_fieldvalues'] = array( 'core_clubs_fieldvalues', array( 'core_clubs_fieldvalues.club_id=core_clubs.id' ) );
			foreach ( $filters as $k => $v )
			{
				if ( is_array( $v ) )
				{
					$where[] = array( Db::i()->findInSet( "field_{$k}", $v ) );
				}
				else
				{
					$where[] = array( "field_{$k}=?", $v );
				}
			}
		}

        $selectClause  = '*';
        $having = NULL;

        /* Location? */
        if ( isset( $location['lat'] ) and isset( $location['lon'] ) )
        {
            /* Make sure co-ordinates are in a valid format regardless of locale */
            $location['lat'] = number_format( $location['lat'], 6, '.', '' );
            $location['lon'] = number_format( $location['lon'], 6, '.', '' );

            $selectClause .= ', ( 3959 * acos( cos( radians(' . $location['lat'] . ') ) * cos( radians( location_lat ) ) * cos( radians( location_long ) - radians( ' . $location['lon'] . ' ) ) + sin( radians( ' . $location['lat'] . ' ) ) * sin( radians( location_lat ) ) ) ) AS distance';
        }
		
		/* Additional where clause */
		if ( $extraWhere )
		{
			if ( is_array( $extraWhere ) )
			{
				$where = array_merge( $where, $extraWhere );
			}
			else
			{
				$where[] = array( $extraWhere );
			}
		}

		/* Extensions */
		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			/* @var ClubAbstract $ext */
			foreach( $ext->clubsWhere( $member ) as $clause )
			{
				$where[] = $clause;
			}
		}
		
		/* Query */
		if( $countOnly )
		{
			$select = Db::i()->select( 'COUNT(*)', 'core_clubs', $where, null, null, null, $having );
		}
		else
		{
			/* If we are sorting by distance, we want null values last */
			$sortOption = ( $sortOption === 'distance' ) ? '-distance' : $sortOption;
			$select = Db::i()->select( $selectClause, 'core_clubs', $where, "{$sortOption} " . ( $sortOption === 'name' ? 'ASC' : 'DESC' ), $limit, null, $having );
		}
		
		foreach ( $joins as $join )
		{
			$select->join( $join[0], $join[1] );
		}

		if( $countOnly )
		{
			return $select->first();
		}

		$select->setKeyField( 'id' );

		/* Return */
		return new ActiveRecordIterator( $select, 'IPS\Member\Club' );
	}	
	
	/**
	 * Get number clubs a member is leader of
	 *
	 * @param	Member	$member	The member
	 * @return	int
	 */
	public static function numberOfClubsMemberIsLeaderOf( Member $member ): int
	{
		return Db::i()->select( 'COUNT(*)', 'core_clubs_memberships', array( 'member_id=? AND status=?', $member->member_id, static::STATUS_LEADER ) )->first();
	}
		
	/* !ActiveRecord */
	
	/**
	 * Set Default Values
	 *
	 * @return	void
	 */
	public function setDefaultValues() : void
	{
		$this->type = static::TYPE_OPEN;
		$this->created = new DateTime;
		$this->last_activity = time();
		$this->members = 1;
		$this->owner = NULL;
		$this->approved = Settings::i()->clubs_require_approval ? 0 : 1;
	}
	
	/**
	 * Get owner
	 *
	 * @return	Member|NULL
	 */
	public function get_owner(): ?Member
	{
		try
		{
			$owner = Member::load( $this->_data['owner'] );
			return $owner->member_id ? $owner : NULL;
		}
		catch( OutOfRangeException $e )
		{
			return NULL;
		}
	}

	/**
	 * Set member
	 *
	 * @param Member|null $owner The owner
	 * @return    void
	 */
	public function set_owner( Member $owner = NULL ) : void
	{
		$this->_data['owner'] = $owner ? ( (int) $owner->member_id ) : NULL;
	}
	
	/**
	 * Get created date
	 *
	 * @return	DateTime
	 */
	public function get_created(): DateTime
	{
		return DateTime::ts( $this->_data['created'] );
	}
	
	/**
	 * Set created date
	 *
	 * @param	DateTime	$date	The creation date
	 * @return	void
	 */
	public function set_created( DateTime $date ) : void
	{
		$this->_data['created'] = $date->getTimestamp();
	}
			
	/**
	 * Get club URL
	 *
	 * @return	Url|string|null
	 */
	function url(): Url|string|null
	{
		return Url::internal( "app=core&module=clubs&controller=view&id={$this->id}", 'front', 'clubs_view', Friendly::seoTitle( $this->name ) );
	}
	
	/**
	 * Columns needed to query for search result / stream view
	 *
	 * @return	array
	 */
	public static function basicDataColumns(): array
	{
		return array( 'id', 'name' );
	}
	
	/**
	 * Edit Club Form
	 *
	 * @param bool $acp			TRUE if editing in the ACP
	 * @param bool $new			TRUE if creating new
	 * @param array|null $availableTypes	If creating new, the available types
	 * @return	Form|NULL
	 */
	public function form( bool $acp=FALSE, bool $new=FALSE, array $availableTypes=NULL ): ?Form
	{
		$form = new Form;
		
		$form->add( new Text( 'club_name', $this->name, TRUE, array( 'maxLength' => 255 ) ) );
		
		if ( $acp or ( $new and count( $availableTypes ) > 1 ) )
		{
			$form->add( new Radio( 'club_type', $this->type, TRUE, array(
				'options' => $new ? $availableTypes : array(
					Club::TYPE_PUBLIC	=> 'club_type_' . Club::TYPE_PUBLIC,
					Club::TYPE_OPEN	=> 'club_type_' . Club::TYPE_OPEN,
					Club::TYPE_CLOSED	=> 'club_type_' . Club::TYPE_CLOSED,
					Club::TYPE_PRIVATE	=> 'club_type_' . Club::TYPE_PRIVATE,
					Club::TYPE_READONLY	=> 'club_type_' . Club::TYPE_READONLY,
				),
				'toggles'	=> array(
					Club::TYPE_OPEN		=> array( 'club_membership_fee', 'club_show_membertab' ),
					Club::TYPE_CLOSED	=> array( 'club_membership_fee', 'club_show_membertab' ),
					Club::TYPE_PRIVATE	=> array( 'club_membership_fee', 'club_show_membertab' ),
					Club::TYPE_READONLY	=> array( 'club_membership_fee', 'club_show_membertab' ),
				)
			) ) );
			
			if ( $acp )
			{
				$form->add( new Form\Member( 'club_owner', $this->owner, TRUE ) );
			}
		}
		
		$form->add( new TextArea( 'club_about', $this->about ) );

		$memberTabFieldPosition = '';

		if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->clubs_paid_on and Member::loggedIn()->group['gbw_paid_clubs'] )
		{
			$form->add( new Radio( 'club_membership_fee', ( $this->id and $this->fee ) ? 'paid' : 'free', TRUE, array(
				'options' => array(
					'free'	=> 'club_membership_free',
					'paid'	=> 'club_membership_paid'
				),
				'toggles' => array(
					'paid'	=> array( 'club_fee', 'club_renewals' )
				)
			), NULL, NULL, NULL, 'club_membership_fee' ) );
			
			$commissionBlurb = NULL;
			$fees = NULL;
			if ( $_fees = Settings::i()->clubs_paid_transfee )
			{
				$fees = array();
				foreach ( $_fees as $fee )
				{
					$fees[] = (string) ( new Money( $fee['amount'], $fee['currency'] ) );
				}
				$fees = Member::loggedIn()->language()->formatList( $fees, Member::loggedIn()->language()->get('or_list_format') );
			}
			if ( Settings::i()->clubs_paid_commission and $fees )
			{
				$commissionBlurb = Member::loggedIn()->language()->addToStack( 'club_fee_desc_both', FALSE, array( 'sprintf' => array( Settings::i()->clubs_paid_commission, $fees ) ) );
			}
			elseif ( Settings::i()->clubs_paid_commission )
			{
				$commissionBlurb = Member::loggedIn()->language()->addToStack('club_fee_desc_percent', FALSE, array( 'sprintf' => Settings::i()->clubs_paid_commission ) );
			}
			elseif ( $fees )
			{
				$commissionBlurb = Member::loggedIn()->language()->addToStack('club_fee_desc_fee', FALSE, array( 'sprintf' => $fees ) );
			}
			
			Member::loggedIn()->language()->words['club_fee_desc'] = $commissionBlurb;
			$form->add( new \IPS\nexus\Form\Money( 'club_fee', $this->id ? json_decode( $this->fee, TRUE ) : array(), NULL, array(), function( $value ){

				if ( count( $value ) == 0 )
				{
					throw new DomainException( 'form_required' );
				}
				
				foreach( $value as $currency => $fee )
				{
					if( !$fee->amount->isGreaterThanZero() )
					{
						throw new DomainException( 'form_required' );
					}
				}
			}, NULL, NULL, 'club_fee' ) );
			$form->add( new Radio( 'club_renewals', $this->id ? ( $this->renewal_term ? 1 : 0 ) : 0, TRUE, array(
				'options'	=> array( 0 => 'club_renewals_off', 1 => 'club_renewals_on' ),
				'toggles'	=> array( 1 => array( 'club_renewal_term' ) )
			), NULL, NULL, NULL, 'club_renewals' ) );
			Member::loggedIn()->language()->words['club_renewal_term_desc'] = $commissionBlurb;
			$renewTermForEdit = NULL;
			if ( $this->id and $this->renewal_term )
			{
				$renewPrices = array();
				foreach ( json_decode( $this->renewal_price, TRUE ) as $currency => $data )
				{
					$renewPrices[ $currency ] = new Money( $data['amount'], $currency );
				}
				$renewTermForEdit = new RenewalTerm( $renewPrices, new DateInterval( 'P' . $this->renewal_term . mb_strtoupper( $this->renewal_units ) ) );
			}
			$form->add( new \IPS\nexus\Form\RenewalTerm( 'club_renewal_term', $renewTermForEdit, NULL, array( 'allCurrencies' => TRUE ), NULL, NULL, NULL, 'club_renewal_term' ) );

			$memberTabFieldPosition = 'club_renewal_term';
		}
		
		$form->add( new Upload( 'club_profile_photo', $this->profile_photo ? File::get( 'core_Clubs', $this->profile_photo ) : NULL, FALSE, array( 'storageExtension' => 'core_Clubs', 'allowStockPhotos' => TRUE, 'image' => array( 'maxWidth' => 200, 'maxHeight' => 200 ) ) ) );
		
		if ( Settings::i()->clubs_locations )
		{
			$form->add( new Address( 'club_location', $this->location_json ? GeoLocation::buildFromJson( $this->location_json ) : NULL, FALSE, array( 'requireFullAddress' => FALSE, 'minimize' => !$this->location_json, 'preselectCountry' => FALSE ) ) );
		}
		
		$fieldValues = $this->fieldValues();
		foreach ( CustomField::roots() as $field )
		{
			if ( $field->type === 'Editor' )
			{
				if ( $field->allow_attachments AND !$new )
				{
					$field::$editorOptions = array_merge( $field::$editorOptions, array( 'attachIds' => array( $this->id, $field->id, NULL ), 'autoSaveKey' => "clubs-field{$field->id}-{$this->id}" ) );
				}
			}
			$helper = $field->buildHelper( $fieldValues["field_{$field->id}"] ?? NULL );
			if ( $field->type === 'Editor' )
			{
				if ( $field->allow_attachments AND $new )
				{
					$field::$editorOptions = array_merge( $field::$editorOptions, array( 'attachIds' => array( NULL ), 'autoSaveKey' => "clubs-field{$field->id}-new" ) );
				}
			}
			$form->add( $helper );
		}


		if ( $acp or ( $new and count( $availableTypes ) > 1 ) )
		{
			$form->add( new Radio( 'club_show_membertab', ( $this AND $this->show_membertab ) ? $this->show_membertab : 'nonmember', TRUE, array( 'options' => array(
				'nonmember'	=> 'club_membertab_everyone',
				'member'		=> 'club_membertab_members',
				'moderator'	=> 'club_membertab_moderators'
			) ),  NULL, NULL, NULL, 'club_show_membertab' ),$memberTabFieldPosition );
		}
		/* We want to show the member page configuration also while editing the club */
		else if ( !$new )
		{
			$form->add( new Radio( 'club_show_membertab', ( $this AND $this->show_membertab ) ? $this->show_membertab : 'nonmember', TRUE, array( 'options' => array(
				'nonmember'	=> 'club_membertab_everyone',
				'member'		=> 'club_membertab_members',
				'moderator'	=> 'club_membertab_moderators'
			) ),  NULL, NULL, NULL, 'club_show_membertab' ), $memberTabFieldPosition );
		}
		
		$form->add( new YesNo( 'show_rules', ( !$new AND $this->rules ), FALSE, array(
			'togglesOn'	=> array(
				'club_rules',
				'club_rules_required'
			)
		) ) );
		$form->add( new Editor( 'club_rules', $this->rules ?: NULL, FALSE, array(
			'app'			=> 'core',
			'key'			=> 'ClubRules',
			'attachIds'		=> ( $new ) ? array( NULL, NULL, NULL ) : array( $this->id, NULL, 'rules' ),
			'autoSaveKey'	=> ( $new ) ? "club-rules-new" : "club-rules-{$this->id}"
		), NULL, NULL, NULL, 'club_rules' ) );
		$form->add( new YesNo( 'club_rules_required', $this->rules_required, FALSE, array( 'togglesOn' => array( 'club_rules_reacknowledge' ) ), NULL, NULL, NULL, 'club_rules_required' ) );
		
		/* Only show this if we're editing a club */
		if ( !$new )
		{
			$form->add( new YesNo( 'club_rules_reacknowledge', FALSE, FALSE, [], NULL, NULL, NULL, 'club_rules_reacknowledge' ) );
		}

		$form->add( new YesNo( 'club_auto_follow', $this->auto_follow, false ) );
		if( !$new )
		{
			Member::loggedIn()->language()->words['club_auto_follow_desc'] = Member::loggedIn()->language()->get( 'club_auto_follow_desc_edit' );
		}

		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			/* @var ClubAbstract $ext */
			foreach( $ext->formElements( $this ) as $element )
			{
				if( is_object( $element ) )
				{
					$form->add( $element );
				}
				else
				{
					$form->addHtml( $element );
				}
			}
		}

		return $form;
	}

	/**
	 * Process Club Form
	 *
	 * @param $values
	 * @param bool $acp TRUE if editing in the ACP
	 * @param bool $new TRUE if creating new
	 * @param array|null $availableTypes If creating new, the available types
	 * @return    Form|NULL
	 */
	public function processForm($values, bool $acp, bool $new = FALSE, array $availableTypes = NULL): ?Form
	{
		$this->name = $values['club_name'];

		/* If there is only one type available, set it. */
		if( is_array( $availableTypes ) AND count( $availableTypes ) == 1 )
		{
			$values['club_type'] = key( $availableTypes );
		}

		$needToUpdatePermissions = FALSE;
		if ( $acp )
		{
			if ( $this->type != $values['club_type'] )
			{
				$this->type = $values['club_type'];
				$needToUpdatePermissions = TRUE;
			}
			if ( $this->owner != $values['club_owner'] )
			{
				$this->owner = $values['club_owner'];
				$this->addMember( $values['club_owner'], Club::STATUS_LEADER, TRUE );

				/* Update purchases for commission */
				if( Application::appIsEnabled( 'nexus' ) and Settings::i()->clubs_paid_on )
				{
					Db::i()->update( 'nexus_purchases', array( 'ps_pay_to' => $this->owner->member_id ), array( "ps_pay_to IS NOT NULL and ps_app=? and ps_type=? and ps_item_id=?", 'core', 'club', $this->id ) );
				}
			}
		}
		elseif ( $new )
		{
			$this->type = $values['club_type'];
			$this->owner = Member::loggedIn();
		}

		$this->about = $values['club_about'];
		$this->profile_photo = (string) $values['club_profile_photo'];

		if( $values['club_profile_photo'] )
		{
			$this->profile_photo_uncropped = (string) $values['club_profile_photo'];
		}

		if ( isset( $values['club_location'] ) )
		{
			$this->location_json = json_encode( $values['club_location'] );
			if ( $values['club_location']->lat and $values['club_location']->long )
			{
				$this->location_lat = $values['club_location']->lat;
				$this->location_long = $values['club_location']->long;
			}
			else
			{
				$this->location_lat = NULL;
				$this->location_long = NULL;
			}
		}
		else
		{
			$this->location_json = NULL;
			$this->location_lat = NULL;
			$this->location_long = NULL;
		}

		if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->clubs_paid_on and Member::loggedIn()->group['gbw_paid_clubs'] )
		{
			switch ( $values['club_membership_fee'] )
			{
				case 'free':
					$this->fee = NULL;
					$this->renewal_term = 0;
					$this->renewal_units = NULL;
					$this->renewal_price = NULL;
					break;

				case 'paid':
					$this->fee = json_encode( $values['club_fee'] );

					if ( $values['club_renewals'] and $values['club_renewal_term'] )
					{
						$term = $values['club_renewal_term']->getTerm();
						$this->renewal_term = $term['term'];
						$this->renewal_units = $term['unit'];
						$this->renewal_price = json_encode( $values['club_renewal_term']->cost );
					}
					else
					{
						$this->renewal_term = 0;
						$this->renewal_units = NULL;
						$this->renewal_price = NULL;
					}
					break;
			}
		}

		if ( array_key_exists( 'club_show_membertab', $values ) )
		{
			$this->show_membertab		= $values['club_show_membertab'];
		}
		else
		{
			/* Default is "Everybody" */
			$this->show_membertab		= "nonmember";
		}

		$this->auto_follow = $values['club_auto_follow'];
		$autoFollow = ( !$new and isset( $this->changed['auto_follow'] ) and $this->changed['auto_follow'] );

		$this->save();

		if ( $values['show_rules'] )
		{
			$this->rules = $values['club_rules'];
			File::claimAttachments( ( $new ) ? "club-rules-new" : "club-rules-{$this->id}", $this->id, NULL, 'rules' );
			$this->rules_required = (bool) $values['club_rules_required'];

			/* Do we need to reset the acknowledge flags? */
			if ( !$new AND array_key_exists( 'club_rules_reacknowledge', $values ) )
			{
				if ( $values['club_rules_reacknowledge'] )
				{
					Db::i()->update( 'core_clubs_memberships', array( "rules_acknowledged" => FALSE ), array( "club_id=?", $this->id ) );
				}
			}
		}
		else
		{
			$this->rules			= NULL;
			$this->rules_required	= FALSE;

			if ( !$new )
			{
				/* If this isn't a new club, update all memberships in case they decide to re-add rules later on */
				Db::i()->update( "core_clubs_memberships", array( 'rules_acknowledged' => FALSE ), array( "club_id=?", $this->id ) );
			}
		}

		/* If we are auto-following and this is not a new club, trigger a background task */
		if( $autoFollow )
		{
			Task::queue( 'core', 'AutoFollowClubs', [ 'club' => $this->id, 'node' => null ], 3, [ 'club', 'node' ] );
		}

		if ( $new )
		{
			$this->addMember( Member::loggedIn(), Club::STATUS_LEADER );
			$this->acknowledgeRules( Member::loggedIn() );
		}
		$this->recountMembers();

		$customFieldValues = array();
		foreach ( CustomField::roots() as $field )
		{
			if ( isset( $values["core_clubfield_{$field->id}"] ) )
			{
				$helper							 			= $field->buildHelper();

				if ( $helper instanceof Upload )
				{
					$customFieldValues[ "field_{$field->id}" ] = (string) $values["core_clubfield_{$field->id}"];
				}
				else
				{
					$customFieldValues[ "field_{$field->id}" ]	= $helper::stringValue( $values["core_clubfield_{$field->id}"] );
				}

				if ( $field->type === 'Editor' )
				{
					$field->claimAttachments( $this->id );
				}
			}
		}
		if ( count( $customFieldValues ) )
		{
			$customFieldValues['club_id'] = $this->id;
			Db::i()->insert( 'core_clubs_fieldvalues', $customFieldValues, TRUE );
		}

		if ( $needToUpdatePermissions )
		{
			foreach ( $this->nodes() as $node )
			{
				try
				{
					$nodeClass = $node['node_class'];
					$node = $nodeClass::load( $node['node_id'] );
					$node->setPermissionsToClub( $this );
				}
				catch ( \Exception $e ) { }
			}
		}

		/* Extensions */
		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			/* @var ClubAbstract $ext */
			$ext->saveForm( $this, $values, $new );
		}

		/* Default nodes */
		if( $new )
		{
			foreach( Template::roots() as $template )
			{
				$nodeClass = new ( $template->node_class );
				$nodeClass->saveClubForm( $this, $template->node_data, $template );
			}
		}

		if( $new and Settings::i()->clubs_require_approval and !$this->approved )
		{
			/* Add it to the approval queue */
			Approval::create( $this );

			$this->sendModeratorApprovalNotification();
		}

		if( $new and $this->approved )
		{
			Webhook::fire( 'club_created', $this );
			$this->owner->achievementAction( 'core', 'NewClub', $this );

		}
		else if( !$new AND isset( $this->changed['approved'] ) )
		{
			$this->onApprove();
		}

		if( $new )
		{
			Event::fire( 'onCreate', $this );
		}
		else
		{
			Event::fire( 'onEdit', $this );
		}

		return NULL;
	}

	/**
	 * Redirect after save
	 *
	 * @param Club $old A clone of the club as it was before
	 * @param Club $new The club now
	 * @return    array
	 */
	public static function renewalChanges(Club $old, Club $new): array
	{
		$changes = array();

		foreach ( array( 'renewal_term', 'renewal_units', 'renewal_price' ) as $k )
		{
			if ( $old->$k != $new->$k )
			{
				$changes[ $k ] = $old->$k;
			}
		}

		return $changes;
	}

	/**
	 * Send moderator notice of new club pending approval
	 *
	 * @param Member|null $savingMember		Member saving the club or NULL for currently logged in member
	 * @return void
	 */
	public function sendModeratorApprovalNotification( Member $savingMember = NULL ) : void
	{
		$savingMember = $savingMember ?? Member::loggedIn();

		/* Send notification to mods */
		$moderators = array( 'm' => array(), 'g' => array() );
		foreach ( Db::i()->select( '*', 'core_moderators' ) as $mod )
		{
			$canView = FALSE;
			if ( $mod['perms'] == '*' )
			{
				$canView = TRUE;
			}
			if ( $canView === FALSE )
			{
				$perms = json_decode( $mod['perms'], TRUE );

				if ( isset( $perms['can_access_all_clubs'] ) AND $perms['can_access_all_clubs'] === TRUE )
				{
					$canView = TRUE;
				}
			}
			if ( $canView === TRUE )
			{
				$moderators[ $mod['type'] ][] = $mod['id'];
			}
		}
		$notification = new Notification( Application::load('core'), 'unapproved_club', $this, array( $this ) );
		foreach ( Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $member )
		{
			if( $member['member_id'] != $savingMember->member_id )
			{
				$notification->recipients->attach( Member::constructFromData( $member ) );
			}
		}

		if( count( $notification->recipients ) )
		{
			$notification->send();
		}
	}
	
	/**
	 * Custom Field Values
	 *
	 * @return	array
	 */
	public function fieldValues(): array
	{
		try
		{
			$values = Db::i()->select( '*', 'core_clubs_fieldvalues', array( 'club_id=?', $this->id ) )->first();

			/* If we do not have any custom fields, then this query just returns an INT as there is only one column */
			if ( is_numeric( $values ) )
			{
				return [];
			}
			$values = array_filter( $values, function ( $val) { return ( $val !== '' AND $val !== NULL ); } );

			return $values;
		}
		catch ( UnderflowException $e )
		{
			return [];
		}
	}
	
	/**
	 * Cover Photo
	 *
	 * @param bool $getOverlay	If FALSE, will not set the overlay, which saves queries if it will not be used (such as in clubCard)
	 * @param string $position	Position of cover photo
	 * @param Model|ClubPage|null	$container	Node or Page currently viewing
	 * @return    CoverPhoto
	 */
	public function coverPhoto( bool $getOverlay=TRUE, string $position='full', Model|ClubPage|null $container=null ): CoverPhoto
	{
		$photo = new CoverPhoto;

		$photo->maxSize = Settings::i()->club_max_cover;
		if ( $this->cover_photo )
		{
			$photo->file = File::get( 'core_Clubs', $this->cover_photo );
			$photo->offset = $this->cover_offset;
		}
		if ( $getOverlay )
		{
			$photo->overlay = Theme::i()->getTemplate( 'clubs', 'core', 'front' )->coverPhotoOverlay( $this, $position, $container );
		}
		$photo->editable = $this->isLeader();
		$photo->object = $this;

		return $photo;
	}

	/**
	 * Produce a random hex color for a background
	 *
	 * @return string
	 */
	public function coverPhotoBackgroundColor(): string
	{
		return $this->staticCoverPhotoBackgroundColor( $this->name );
	}
	
	/**
	 * Location
	 *
	 * @return	GeoLocation|NULL
	 */
	public function location(): ?GeoLocation
	{
		if ( $this->location_json )
		{
			return GeoLocation::buildFromJson( $this->location_json );
		}
		return NULL;
	}
	
	/**
	 * Is paid?
	 *
	 * @return	bool
	 */
	public function isPaid(): bool
	{
		if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->clubs_paid_on and $this->fee )
		{
			return TRUE;
		}
		
		return FALSE;
	}
	
	/**
	 * Message to explain paid club joining process
	 *
	 * @return	string
	 */
	public function memberFeeMessage(): string
	{
		if ( $this->type === static::TYPE_CLOSED )
		{
			return Member::loggedIn()->language()->addToStack( 'club_closed_join_fee', FALSE, array( 'sprintf' => array( $this->priceBlurb() ) ) );
		}
		else
		{
			return Member::loggedIn()->language()->addToStack( 'club_open_join_fee', FALSE, array( 'sprintf' => array( $this->priceBlurb() ) ) );
		}
	}
	
	/**
	 * Joining fee
	 *
	 * @param string|null $currency	Desired currency, or NULL to choose based on member's chosen currency
	 * @return	Money|NULL
	 */
	public function joiningFee( string $currency = NULL ) : ?Money
	{
		if ( $this->isPaid() )
		{
			if ( !$currency )
			{
				$currency = ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : Customer::loggedIn()->defaultCurrency();
			}
			
			$costs = json_decode( $this->fee, TRUE );
			
			if ( is_array( $costs ) and isset( $costs[ $currency ]['amount'] ) and $costs[ $currency ]['amount'] )
			{
				return new Money( $costs[ $currency ]['amount'], $currency );
			}
		}
		
		return NULL;
	}
	
	/**
	 * Renewal fee
	 *
	 * @param string|null $currency	Desired currency, or NULL to choose based on member's chosen currency
	 * @return	Money|RenewalTerm|NULL
	 * @throws	OutOfRangeException
	 */
	public function renewalTerm( string $currency = NULL ): Money|RenewalTerm|null
	{
		if ( $this->renewal_price and $renewalPrices = json_decode( $this->renewal_price, TRUE ) )
		{
			if ( !$currency )
			{
				$currency = ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : Customer::loggedIn()->defaultCurrency();
			}
			
			if ( isset( $renewalPrices[ $currency ] ) )
			{
				return new RenewalTerm( new Money( $renewalPrices[ $currency ]['amount'], $currency ), new DateInterval( 'P' . $this->renewal_term . mb_strtoupper( $this->renewal_units ) ) );
			}
			else
			{
				throw new OutOfRangeException;
			}
		}
		
		return NULL;
	}
	
	/**
	 * Price Blurb
	 *
	 * @return	Money|string|RenewalTerm|null
	 */
	public function priceBlurb(): Money|string|RenewalTerm|null
	{
		if ( Application::appIsEnabled( 'nexus' ) and Settings::i()->clubs_paid_on )
		{
			if ( $this->isPaid() )
			{				
				if ( $fee = $this->joiningFee() )
				{
					/* Include tax? */
					$taxRate = NULL;
					if ( Settings::i()->nexus_show_tax and Settings::i()->clubs_paid_tax )
					{
						try
						{
							$taxRate = new Number( Tax::load( Settings::i()->clubs_paid_tax )->rate( Customer::loggedIn()->estimatedLocation() ) );
						}
						catch ( OutOfRangeException $e ) {}
					}
							
					if ( $taxRate )
					{
						$fee->amount = $fee->amount->add( $fee->amount->multiply( $taxRate ) );
					}
				
					try
					{
						$renewalTerm = $this->renewalTerm( $fee->currency );
						
						if ( $renewalTerm and $taxRate )
						{
							$renewalTerm->cost->amount = $renewalTerm->cost->amount->add( $renewalTerm->cost->amount->multiply( $taxRate ) );
						}
						
						if ( !$renewalTerm )
						{
							return $fee;
						}
						else if ( $renewalTerm->cost->amount === $fee->amount )
						{
							return $renewalTerm;
						}
						else
						{
							return Member::loggedIn()->language()->addToStack( 'club_fee_plus_renewal', FALSE, array( 'sprintf' => array( $fee, $renewalTerm ) ) );
						}
					}
					catch ( OutOfRangeException $e )
					{
						return Member::loggedIn()->language()->addToStack('club_paid_unavailable');
					}
				}
				else
				{
					return Member::loggedIn()->language()->addToStack('club_paid_unavailable');
				}
			}
			else
			{
				return Member::loggedIn()->language()->addToStack('club_membership_free');
			}
		}
		return NULL;
	}

	/**
	 * Generate invoice for a member
	 *
	 * @param	Customer|null	$member	Member to generate the invoice for
	 * @return	Url
	 */
	public function generateInvoice( Customer $member = NULL ): Url
	{
		$member = $member ?: Customer::loggedIn();

		$fee = $this->joiningFee();

		/* Create the item */		
		$item = new ClubMembership( $this->name, $fee );
		$item->id = $this->id;
		try
		{
			$item->tax = Settings::i()->clubs_paid_tax ? Tax::load( Settings::i()->clubs_paid_tax ) : NULL;
		}
		catch ( OutOfRangeException $e ) { }
		if ( Settings::i()->clubs_paid_gateways )
		{
			$item->paymentMethodIds = explode( ',', Settings::i()->clubs_paid_gateways );
		}
		$item->renewalTerm = $this->renewalTerm( $fee->currency );
		$item->payTo = $this->owner;
		$item->commission = Settings::i()->clubs_paid_commission;
		if ( $fees = Settings::i()->clubs_paid_transfee and isset( $fees[ $fee->currency ] ) )
		{
			$item->fee = new Money( $fees[ $fee->currency ]['amount'], $fee->currency );
		}
		
		/* Generate the invoice */
		$invoice = new Invoice;
		$invoice->currency = $fee->currency;
		$invoice->member = $member;
		$invoice->addItem( $item );
		$invoice->return_uri = "app=core&module=clubs&controller=view&id={$this->id}";
		$invoice->save();

		return $invoice->checkoutUrl();
	}
	
		
	/* !Manage Memberships */
		
	/**
	 * Get members
	 *
	 * @param array $statuses			The membership statuses to get
	 * @param array|int|null $limit				Rows to fetch or array( offset, limit )
	 * @param string|null $order				ORDER BY clause
	 * @param int $returnType			0 = core_clubs_memberships rows, 1 = core_clubs_memberships plus \IPS\Member::columnsForPhoto(), 2 = full core_members rows, 3 = same as 1 but also getting name of adder/invitee, 4 = count only, 5 = same as 3 but also getting expire date
	 * @return	Select|int
	 */
	public function members( array $statuses = array( 'member', 'moderator', 'leader' ), array|int|null $limit = 25, ?string $order = 'core_clubs_memberships.joined ASC', int $returnType = 1 ): Select|int
	{	
		if ( $returnType === 4 )
		{
			return Db::i()->select( 'COUNT(*)', 'core_clubs_memberships', array( array( 'club_id=?', $this->id ), array( Db::i()->in( 'status', $statuses ) ) ) )->first();
		}
		else
		{
			if ( $returnType === 2 )
			{
				$columns = 'core_members.*';
			}
			else
			{
				$columns = 'core_clubs_memberships.member_id,core_clubs_memberships.joined,core_clubs_memberships.status,core_clubs_memberships.added_by,core_clubs_memberships.invited_by';
				if ( $returnType === 1 or $returnType === 3 or $returnType === 5 )
				{
					$columns .= ',' . implode( ',', array_map( function( $column ) {
						return 'core_members.' . $column;
					}, Member::columnsForPhoto() ) );
				}
				if ( $returnType === 3 or $returnType === 5 )
				{
					$columns .= ',added_by.name,invited_by.name';
					
					if ( $returnType === 5 and Application::appIsEnabled('nexus') and Settings::i()->clubs_paid_on and $this->isPaid() and $this->renewal_price )
					{
						$columns .= ',nexus_purchases.ps_active,nexus_purchases.ps_expire';
					}
				}
			}
			
			$select = Db::i()->select( $columns, 'core_clubs_memberships', array( array( 'club_id=?', $this->id ), array( Db::i()->in( 'status', $statuses ) ) ), $order, $limit, NULL, NULL, Db::SELECT_MULTIDIMENSIONAL_JOINS );
		}

		if ( $returnType === 1 or $returnType === 2 or $returnType === 3 or $returnType === 5 )
		{
			$select->join( 'core_members', 'core_members.member_id=core_clubs_memberships.member_id' );
		}
		if ( $returnType === 3 or $returnType === 5 )
		{
			$select->join( array( 'core_members', 'added_by' ), 'added_by.member_id=core_clubs_memberships.added_by' );
			$select->join( array( 'core_members', 'invited_by' ), 'invited_by.member_id=core_clubs_memberships.invited_by' );
			
			if ( $returnType === 5 and Application::appIsEnabled('nexus') and Settings::i()->clubs_paid_on and $this->isPaid() and $this->renewal_price )
			{
				$select->join( 'nexus_purchases', array( 'ps_app=? AND ps_type=? AND ps_member=core_clubs_memberships.member_id AND ps_item_id=? AND ps_cancelled=0', 'core', 'club', $this->id ) );
			}
		}

		return $select;
	}	
	
	/**
	 * @brief	Cache of randomTenMembers()
	 */
	protected ?array $_randomTenMembers = NULL;
	
	/**
	 * Get basic data of a random ten members in the club (for cards)
	 *
	 * @return	array|null
	 */
	public function randomTenMembers(): ?array
	{
		if ( !isset( $this->_randomTenMembers ) )
		{
			$this->_randomTenMembers = iterator_to_array( $this->members( array( 'leader', 'moderator', 'member' ), 10, 'RAND()' ) );
		}
		return $this->_randomTenMembers;
	}
	
	/**
	 * Add a member
	 *
	 * @param	Member			$member		The member
	 * @param string $status		Status
	 * @param bool $update		Update membership if already a member?
	 * @param	Member|NULL	$addedBy	The leader who added them, or NULL if joining themselves
	 * @param	Member|NULL	$invitedBy	The member who invited them, or NULL if joining themselves
	 * @param bool $updateJoinedDate	Whether to update the joined date or not (FALSE by default, set to TRUE when an invited member accepts)
	 * @return	void
	 * @throws	OverflowException	Member is already in the club and $update was FALSE
	 */
	public function addMember( Member $member, string $status = 'member', bool $update=FALSE, Member $addedBy = NULL, Member $invitedBy = NULL, bool $updateJoinedDate=FALSE ) : void
	{
		/* Get the current status so that we can figure out if this is a new membership */
		$currentStatus = $this->memberStatus( $member );

		try
		{
			Db::i()->insert( 'core_clubs_memberships', array(
				'club_id'	=> $this->id,
				'member_id'	=> $member->member_id,
				'joined'	=> time(),
				'status'	=> $status,
				'added_by'	=> $addedBy?->member_id,
				'invited_by'=> $invitedBy?->member_id
			) );

			$member->rebuildPermissionArray();
			$this->memberStatuses[ $member->member_id ] = $status;
		}
		catch ( Exception $e )
		{
			if ( $e->getCode() === 1062 )
			{
				if ( $update )
				{
					$save = array( 'status'	=> $status );
					if ( $addedBy )
					{
						$save['added_by'] = $addedBy->member_id;
					}

					if ( $invitedBy )
					{
						$save['invited_by'] = $invitedBy->member_id;
					}
				
					if( $updateJoinedDate === TRUE )
					{
						$save['joined']	= time();
					}
								
					Db::i()->update( 'core_clubs_memberships', $save, array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) );
					
					$member->rebuildPermissionArray();
					$this->memberStatuses[ $member->member_id ] = $status;
				}
				else
				{
					throw new OverflowException;
				}
			}
			else
			{
				throw $e;
			}
		}

		/* Is this club an auto-follow? */
		if( $this->auto_follow and in_array( $status, [ static::STATUS_MEMBER, static::STATUS_LEADER, static::STATUS_MODERATOR ] ) and !in_array( $currentStatus, [ static::STATUS_MEMBER, static::STATUS_LEADER, static::STATUS_MODERATOR ] ) )
		{
			$this->follow( 'immediate', true, $member );
		}

		/* Log to Member History */
		$memberStatus = $this->memberStatus( $member, 2 );
		if( in_array( $memberStatus['status'], [ Club::STATUS_INVITED, Club::STATUS_INVITED_BYPASSING_PAYMENT, Club::STATUS_BANNED, Club::STATUS_MEMBER, Club::STATUS_DECLINED, Club::STATUS_REQUESTED ] ) )
		{
			if ( $memberStatus['status'] == Club::STATUS_INVITED )
			{
				$addedBy = Member::load( $memberStatus['invited_by'] );
			}
			$member->logHistory( 'core', 'club_membership', array('club_id' => $this->id, 'type' => $status ), $addedBy );
		}

		$params = [
			'club' => $this,
			'member' => $member,
			'status' => $status
		];

		Webhook::fire( 'club_member_added', $params );

        Event::fire( 'onJoinClub', $member, array( $this ) );
		
		/* Achievements */
		if( in_array( $status, array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER, static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ) ) )
		{
			$member->achievementAction( 'core', 'JoinClub', $this );
		}		
	}

	/**
	 * Send an invitation to a member
	 *
	 * @param	Member		$inviter	Person doing the inviting
	 * @param array $members	Array of members being invited
	 * @return	void
	 */
	public function sendInvitation( Member $inviter, array $members ) : void
	{
		$notification = new Notification( Application::load('core'), 'club_invitation', $this, array( $this, $inviter ), array( 'invitedBy' => $inviter->member_id ) );
		foreach ( $members as $member )
		{
			if ( $member instanceof Member )
			{
				$memberStatus = $this->memberStatus( $member );
				if ( !$memberStatus or in_array( $memberStatus, array( Club::STATUS_INVITED, Club::STATUS_REQUESTED, Club::STATUS_DECLINED, Club::STATUS_BANNED ) ) )
				{
					$notification->recipients->attach( $member );
				}
			}
		}
		$notification->send();
	}
	
	/**
	 * Remove a member
	 *
	 * @param	Member	$member		The member
	 * @return	void
	 */
	public function removeMember( Member $member ) : void
	{
		Db::i()->delete( 'core_clubs_memberships', array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) );

		$member->rebuildPermissionArray();

		/* Clear out the member from any local caches */
		if( isset( $this->memberStatuses[ $member->member_id ] ) )
		{
			unset( $this->memberStatuses[ $member->member_id ] );
		}

		/* Unfollow the club */
		$this->unfollow( $member );

		Webhook::fire( 'club_member_removed', ['club' => $this, 'member' => $member] );

        Event::fire( 'onLeaveClub', $member, array( $this ) );
	}
	
	/**
	 * Recount members
	 *
	 * @return	void
	 */
	public function recountMembers() : void
	{
		$this->members = Db::i()->select( 'COUNT(*)', 'core_clubs_memberships', array( 'club_id=? AND ( status=? OR status=? OR status=? OR status=? OR status=? )', $this->id, static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER, static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ) )->first();
		$this->save();
	}
	
	/* !Manage Nodes */
	
	/**
	 * Get available features
	 *
	 * @param	Member|NULL	$member	If a member object is provided, will only get the types that member can create
	 * @return	array
	 */
	public static function availableNodeTypes( Member $member = NULL ): array
	{
		$return = array();
						
		foreach ( Application::allExtensions( 'core', 'ContentRouter' ) as $contentRouter )
		{
			foreach ( $contentRouter->classes as $class )
			{
				if ( isset( $class::$containerNodeClass ) and IPS::classUsesTrait( $class::$containerNodeClass, ClubContainer::class ) )
				{
					$nodeClass = $class::$containerNodeClass;
					if( !$nodeClass::canBeAddedToClub() )
					{
						continue;
					}

					if ( $member === NULL or $member->group['g_club_allowed_nodes'] === '*' or in_array( $nodeClass, explode( ',', $member->group['g_club_allowed_nodes'] ) ) )
					{
						$return[] = $nodeClass;
					}
				}
			}
		}
				
		return array_unique( $return );
	}
	
	/**
	 * Get Pages
	 *
	 * @return	array
	 */
	public function pages(): array
	{
		$return = array();
		foreach( new ActiveRecordIterator( Db::i()->select( '*', 'core_club_pages', array( "page_club=?", $this->id ) ), 'IPS\Member\Club\Page' ) AS $row )
		{
			$return['page-' . $row->id] = $row;
		}
		return $return;
	}

	/**
	 * @brief	Cached nodes
	 */
	protected ?array $cachedNodes	= NULL;

	/**
	 * Get Node names and URLs
	 *
	 * @return array|null
	 */
	public function nodes(): ?array
	{
		if( $this->cachedNodes === NULL )
		{
			$this->cachedNodes = array();
			
			foreach ( Db::i()->select( '*', 'core_clubs_node_map', array( 'club_id=?', $this->id ) ) as $row )
			{
				$class		= $row['node_class'];
				$classBits	= explode( '\\', $class );
				try
				{
					if( !Application::load( $classBits[1] )->_enabled )
					{
						continue;
					}
				}
				catch( \OutOfRangeException $e)
				{
					continue;
				}


				/* Make sure the class exists. This can happen in the case of Pages databases/categories */
				if( !class_exists( $row['node_class'] ) )
				{
					continue;
				}

				try
				{
					$node = $row['node_class']::load( $row['node_id'] );

					if ( class_exists( '\\IPS\\cms\\Categories' ) and $node instanceof Categories and !( class_exists( '\\IPS\\cms\\Pages\\Page' ) and Page::loadByDatabaseId( $node->database()->_id ) ) )
					{
						continue;
					}
					
					if( $node instanceof Categories )
					{
						if( !$node->canView() )
						{
							continue;
						}
					}

					$this->cachedNodes[ $row['id'] ] = array(
					'name'			=> $row['name'],
					'url'			=> $node->url(),
					'node_class'	=> $row['node_class'],
					'node_id'		=> $row['node_id'],
					'public'		=> $row['public']
					);
				}
				catch( OutOfRangeException $e )
				{
					Log::log( 'Missing club node ' . $row['node_class'] . ' ' . $row['node_id'] . " is being loaded.", 'club_nodes');
				}
			}
		}
		return $this->cachedNodes;
	}
	
	/* !Permissions */
	
	/**
	 * Load and check permissions
	 *
	 * @param	mixed	$id		ID
	 * @return	static
	 * @throws	OutOfRangeException
	 */
	public static function loadAndCheckPerms( mixed $id ): static
	{
		$obj = static::load( $id );

		if ( !$obj->canView() )
		{
			throw new OutOfRangeException;
		}

		return $obj;
	}
	
	/**
	 * Can a member see this club and who's in it?
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canView( Member $member = NULL ): bool
	{
		$member = $member ?: Member::loggedIn();
		
		/* If we can't access the module, stop here */
		if ( !$member->canAccessModule( Module::get( 'core', 'clubs', 'front' ) ) )
		{
			return FALSE;
		}

		/* If it's not approved, only moderators and the person who created it can see it */
		if ( Settings::i()->clubs_require_approval and !$this->approved )
		{
			return ( $member->modPermission('can_access_all_clubs') or ( $this->owner AND $member->member_id == $this->owner->member_id ) );
		}
		
		/* Unless it's private, everyone can see it exists */
		if ( $this->type !== static::TYPE_PRIVATE )
		{
			return TRUE;
		}
		
		/* Moderators can see everything */
		if ( $member->modPermission('can_access_all_clubs') )
		{
			return TRUE;
		}
				
		/* Otherwise, only if they're a member or have been invited */		
		return in_array( $this->memberStatus( $member ), array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER, static::STATUS_INVITED, static::STATUS_INVITED_BYPASSING_PAYMENT, static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ) );
	}
	
	/**
	 * Can a member join (or ask to join) this club?
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canJoin( Member $member = NULL ): bool
	{
		/* If it's not approved, nobody can join it */
		if ( Settings::i()->clubs_require_approval and !$this->approved )
		{
			return FALSE;
		}
		
		/* Nobody can join public clubs */
		if ( $this->type === static::TYPE_PUBLIC )
		{
			return FALSE;
		}
		
		/* Guests cannot join clubs */
		$member = $member ?: Member::loggedIn();
		if ( !$member->member_id )
		{
			return FALSE;
		}
		
		/* If they're already a member, or have aleready asked to join, they can't join again */
		$memberStatus = $this->memberStatus( $member );
		if ( in_array( $memberStatus, array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER, static::STATUS_REQUESTED, static::STATUS_DECLINED, static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ) ) )
		{
			return FALSE;
		}

		/* If they are banned, they cannot join */
		if ( $memberStatus === static::STATUS_BANNED )
		{
			return FALSE;
		}
		
		/* If it's private or read-only, they have to be invited */
		if ( $this->type === static::TYPE_PRIVATE or $this->type === static::TYPE_READONLY )
		{
			return in_array( $memberStatus, array( static::STATUS_INVITED, static::STATUS_INVITED_BYPASSING_PAYMENT ) );
		}
		
		/* Otherwise they can join */
		return TRUE;
	}
	
	/**
	 * Can a member see the posts in this club?
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canRead( Member $member = NULL ) : bool
	{
		switch ( $this->type )
		{
			case static::TYPE_PUBLIC:
			case static::TYPE_OPEN:
			case static::TYPE_READONLY:
				return TRUE;
				
			case static::TYPE_CLOSED:
			case static::TYPE_PRIVATE:
				$member = $member ?: Member::loggedIn();
				return ( $member->modPermission('can_access_all_clubs') or in_array( $this->memberStatus( $member ), array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER ) ) );
		}

		return false;
	}
	
	/**
	 * Can a member participate this club?
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canPost( Member $member = NULL ) : bool
	{
		switch ( $this->type )
		{
			case static::TYPE_PUBLIC:
				return TRUE;
				
			case static::TYPE_OPEN:
			case static::TYPE_CLOSED:
			case static::TYPE_PRIVATE:
			case static::TYPE_READONLY:
				$member = $member ?: Member::loggedIn();
				return $member->modPermission('can_access_all_clubs') or in_array( $this->memberStatus( $member ), array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER ) );
		}

		return false;
	}
	
	/**
	 * Can a member invite other members
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canInvite( Member $member = NULL ): bool
	{
		if ( Settings::i()->clubs_require_approval and !$this->approved )
		{
			return FALSE;
		}
		
		switch ( $this->type )
		{
			case static::TYPE_PUBLIC:
				return FALSE;
				
			case static::TYPE_OPEN:
				$member = $member ?: Member::loggedIn();
				return $member->modPermission('can_access_all_clubs') or in_array( $this->memberStatus( $member ), array( static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER ) );
				
			case static::TYPE_CLOSED:
			case static::TYPE_PRIVATE:
			case static::TYPE_READONLY:
				return $this->isLeader( $member );
		}

		return FALSE;
	}

	/**
	 * Does this user have permissions to manage the navigation
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function canManageNavigation( Member $member = NULL ): bool
	{
		$member = $member ?: Member::loggedIn();
		return $this->isLeader( $member );
	}
	
	/**
	 * Does this user have leader permissions in the club?
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function isLeader( Member $member = NULL ): bool
	{
		$member = $member ?: Member::loggedIn();
		return $member->modPermission('can_access_all_clubs') or $this->memberStatus( $member ) === static::STATUS_LEADER;
	}
	
	/**
	 * Does this user have moderator permissions in the club?
	 *
	 * @param	Member|null	$member	The member (NULL for currently logged in member)
	 * @return	bool
	 */
	public function isModerator( Member $member = NULL ) : bool
	{
		$member = $member ?: Member::loggedIn();
		return $member->modPermission('can_access_all_clubs') or in_array( $this->memberStatus( $member ), array( static::STATUS_MODERATOR, static::STATUS_LEADER ) );
	}

	
	/**
	 * @brief	Membership status cache
	 */
	public array $memberStatuses = array();
	
	/**
	 * Get status of a particular member
	 *
	 * @param	Member	$member		The member
	 * @param int $returnType	1 will return a string with the type or NULL if not applicable. 2 will return array with status, joined, accepted_by, invited_by
	 * @return	mixed
	 */
	public function memberStatus( Member $member, int $returnType = 1 ): mixed
	{
		if ( !$member->member_id )
		{
			return NULL;
		}

		if ( !array_key_exists( $member->member_id, $this->memberStatuses ) or $returnType === 2 )
		{
			try
			{
				$val = Db::i()->select( $returnType === 2 ? '*' : array( 'status' ), 'core_clubs_memberships', array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) )->first();
				
				if ( $returnType === 2 )
				{
					return $val;
				}
				else
				{
					$this->memberStatuses[ $member->member_id ] = $val;
				}
			}
			catch ( UnderflowException $e )
			{
				$this->memberStatuses[ $member->member_id ] = NULL;
			}
		}
		
		return $this->memberStatuses[ $member->member_id ];
	}
	
	/* ! Following */

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

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

		Webhook::fire( 'club_deleted', $this );

		foreach( new ActiveRecordIterator( Db::i()->select( '*', 'core_club_pages', array( 'page_club=?', $this->id ) ), 'IPS\Member\Club\Page' ) as $page )
		{
			$page->delete( FALSE );
		}

		try
		{
			Approval::loadFromContent( get_called_class(), $this->id )->delete();
		}
		catch( OutOfRangeException ){}

		$this->coverPhoto( FALSE )->delete();

		Event::fire( 'onDelete', $this );
	}
	
	/**
	 * Remove nodes that are owned by a specific application. Used when uninstalling an app
	 *
	 * @param	Application	$app	The application being deleted
	 * @return void
	 */
	public static function deleteByApplication( Application $app ) : void
	{
		foreach( Db::i()->select( 'node_class', 'core_clubs_node_map', NULL, NULL, NULL, 'node_class' ) as $class )
		{
			if ( class_exists( $class ) and isset( $class::$contentItemClass ) )
			{
				$contentItemClass = $class::$contentItemClass;

				if ( $contentItemClass::$application == $app->directory )
				{
					Db::i()->delete( 'core_clubs_node_map', array( 'node_class=?', $class  ) );
				}
			}
		}
	}

	/**
	 * 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		url				URL to the club
	 * @apiresponse	string		type			Type of club (public, open, closed, private, readonly)
	 * @clientapiresponse	bool	approved	Whether the club is approved or not
	 * @apiresponse	datetime	created			Datetime the club was created
	 * @apiresponse	int			memberCount		Number of members in the club
	 * @apiresponse	\IPS\Member		owner		Club owner
	 * @apiresponse	string|null		photo			URL to the club's profile photo
	 * @apiresponse	bool		paid			Whether the club is paid or not
	 * @apiresponse	bool		featured		Whether the club is featured or not
	 * @apiresponse	\IPS\GeoLocation|NULL		location			Geolocation object representing the club's location, or NULL if no location is available
	 * @apiresponse	string		about			Club 'about' information supplied by owner
	 * @apiresponse	datetime	lastActivity	Datetime of last activity within the club
	 * @apiresponse	int		contentCount		Count of all content items + comments in the club
	 * @apiresponse	string|NULL		coverPhotoUrl		URL to the club's cover photo, or NULL if no cover photo is available
	 * @apiresponse	string		coverOffset			Cover photo offset
	 * @apiresponse	string		coverPhotoColor		Cover photo overlay background color
	 * @apiresponse	[\IPS\Member]		members		Club members
	 * @apiresponse	[\IPS\Member]		leaders		Club leaders
	 * @apiresponse	[\IPS\Member]		moderators		Club moderators
	 * @apiresponse	[\IPS\core\ProfileFields\Api\Field]		fieldValues			Club's custom field values
	 * @apiresponse	[\IPS\Node\Model]		nodes				Nodes created for this club
	 * @apiresponse	\IPS\nexus\Money|null	joiningFee	Cost to join the club, or null if there is no cost
	 * @apiresponse	\IPS\nexus\Purchase\RenewalTerm|null	renewalTerm	Renewal term for the club, or null if there are no renewals
	 * @note	When trying to determine all users who can access the club, the owner object should be combined with all leaders, moderators and members. Only up to 250 members will be returned (sorted by most recently joining the club) but the full member count can be seen with the memberCount property.
	 */
	public function apiOutput( Member $authorizedMember = NULL ): array
	{
		$coverPhoto = NULL;

		if ( $this->cover_photo )
		{
			$coverPhoto = (string) File::get( 'core_Clubs', $this->cover_photo )->url;
		}

		$members		= array();
		$leaders		= array();
		$moderators		= array();

		foreach( $this->members( array( 'member', 'moderator', 'leader' ), 250, 'core_clubs_memberships.joined DESC', 2 ) as $member )
		{
			$member = Member::constructFromData( $member );

			if( $this->owner !== $member )
			{
				if( $this->isLeader( $member ) )
				{
					$leaders[] = $member->apiOutput();
				}
				elseif( $this->isModerator( $member ) )
				{
					$moderators[] = $member->apiOutput();
				}
				else
				{
					$members[] = $member->apiOutput();
				}
			}
		}

		$customFields	= array();
		$fieldValues	= $this->fieldValues();

		if( CustomField::roots() )
		{
			foreach( CustomField::roots() as $field )
			{
				if( isset( $fieldValues['field_' . $field->id ] ) )
				{
					$fieldObject = new Field( Lang::load( Lang::defaultLanguage() )->get( 'core_clubfield_' . $field->id ), $fieldValues[ 'field_' . $field->id ] );
					$customFields[] = $fieldObject->apiOutput( $authorizedMember );
				}
			}
		}

		$return = array(
			'id'				=> $this->id,
			'name'				=> $this->name,
			'url'				=> (string) $this->url(),
			'type'				=> $this->type,
			'created'			=> $this->created->rfc3339(),
			'memberCount'		=> $this->members,
			'owner'				=> $this->owner ? $this->owner->apiOutput() : NULL,
			'photo'				=> $this->profile_photo ? (string) File::get( 'core_Clubs', $this->profile_photo )->url : NULL,
			'featured'			=> (bool) $this->featured,
			'paid'				=> $this->isPaid(),
			'location'			=> ( $location = $this->location() ) ? $location->apiOutput() : NULL,
			'about'				=> $this->about,
			'lastActivity'		=> DateTime::ts( $this->last_activity )->rfc3339(),
			'contentCount'		=> $this->content,
			'coverPhotoUrl'		=> $coverPhoto,
			'coverOffset'		=> $this->cover_offset,
			'coverPhotoColor'	=> $this->coverPhotoBackgroundColor(),
			'members'			=> $members,
			'leaders'			=> $leaders,
			'moderators'		=> $moderators,
			'fieldValues'		=> $customFields,
			'nodes'				=> array_map( function( $node ){
					$node['url'] = (string) $node['url'];
					$node['id']  = $node['node_id'];
					$node['class'] = $node['node_class'];

					unset( $node['node_id'], $node['node_class'] );

					return $node;
				}, $this->nodes() ),
		);

		if( !$authorizedMember )
		{
			$return['approved']	= (bool) $this->approved;
		}

		if ( Application::appIsEnabled( 'nexus' ) )
		{
			$defaultCurrency = $authorizedMember ? Customer::load( $authorizedMember->member_id )->defaultCurrency() : ( new Customer )->defaultCurrency();

			$return['joiningFee']	= $this->joiningFee( $defaultCurrency ) ? $this->joiningFee( $defaultCurrency )->apiOutput() : NULL;
			try
			{
				$return['renewalTerm']	= $this->renewalTerm( $defaultCurrency ) ? $this->renewalTerm( $defaultCurrency )->apiOutput() : NULL;
			}
			catch( OutOfRangeException $e )
			{
				$return['renewalTerm']	= NULL;
			}
		}
		else
		{
			$return['joiningFee'] = NULL;
			$return['renewalTerm']	= NULL;
		}


		return $return;
	}

	/**
	 * @brief	Cached tabs
	 */
	static ?array $tabs = NULL;

	/**
	 * Get the club navbar tabs
	 *
	 * @param	Model|ClubPage|null	$container	Container
	 * @return	array
	 */
	public function tabs( Model|ClubPage|null $container = NULL ) : array
	{
		if ( !static::$tabs )
		{
			$tabs = array();

			$tabs[ 'club_home' ] = array( 'href' => $this->url()->setQueryString('do', 'overview'), 'title' => Member::loggedIn()->language()->addToStack( 'club_home' ), 'isActive' => ( Request::i()->module == 'clubs' AND Request::i()->do == 'overview' ) );

			if  ( $this->canViewMembers() )
			{
				$tabs['club_members'] = array( 'href' => $this->url()->setQueryString('do', 'members'), 'title' => Member::loggedIn()->language()->addToStack( 'club_members' ), 'isActive' => ( Request::i()->module == 'clubs' AND Request::i()->do == 'members' ) );
			}

			foreach( $this->nodes() as $nodeID => $node )
			{
				if  ( $this->canRead() or $node['public'] )
				{
					$tabs[$nodeID] = array( 'href' => $node['url'] , 'title' => $node['name'], 'isActive' => ( isset( $container ) AND get_class( $container ) === $node['node_class'] and $container->_id == $node['node_id'] ) );
				}	
			}
				
			foreach( $this->pages() AS $pageId => $page )
			{
				if ( $page->canView() )
				{
					$tabs[$pageId] = array( 'href' => $page->url(), 'title' => $page->title, 'isActive' => ( Request::i()->module == 'clubs' AND Request::i()->controller == 'page' AND Request::i()->id == $page->id ) );
				}
			}

			$tabs = $this->_tabs( $tabs, $container );

			$changed = FALSE;

			if ( $this->menu_tabs AND $this->menu_tabs != "" )
			{
				$order = array_values( json_decode( $this->menu_tabs , TRUE ) );

				uksort( $tabs, function( $a, $b ) use ( $order, &$changed ) {
					if ( in_array( $a, $order ) and in_array( $b, $order ) )
					{
						return ( array_search( $a, $order ) > array_search( $b, $order ) ? 1 : -1 );
					}
					elseif ( !in_array( $b, $order) )
					{
						/* A new node was added, attach it to the end */
						$changed = TRUE;
						return -1;
					}
					else
					{
						return 0;
					}
				} );
			}

			/* If none of the tabs are active, set the first one as active */
			$hasActive = FALSE;

			foreach( $tabs as $tab )
			{
				if( $tab['isActive'] )
				{
					$hasActive = TRUE;
					break;
				}
			}

			if( !$hasActive )
			{
				$first = key( $tabs );

				$tabs[ $first ]['isActive'] = TRUE;
			}

			if ( $changed )
			{
				$this->menu_tabs = json_encode( array_keys( $tabs ) );
				$this->save();
			}

			static::$tabs = $tabs;
		}

		return static::$tabs;
	}

	/**
	 * Build the club menu
	 *
	 * @param Model|ClubPage|null	$container
	 * @return Menu
	 */
	public function menu( Model|ClubPage|null $container = null ) : Menu
	{
		$menu = new Menu( 'club_manage', css: 'ipsButton ipsButton--inherit' );
		if( Settings::i()->clubs_header == 'sidebar' )
		{
			$menu->css .= ' i-width_100p i-margin-top_2';
		}

		if( !$this->isLeader() and $this->owner->member_id != Member::loggedIn()->member_id and !Member::loggedIn()->modPermission( 'can_access_all_clubs' ) )
		{
			return $menu;
		}

		if( !Settings::i()->clubs_require_approval or $this->approved )
		{
			if ( $this->isLeader() and $nodeTypes = static::availableNodeTypes() )
			{
				foreach( $nodeTypes as $nodeType )
				{
					$menu->add( new Link( $this->url()->setQueryString( [ 'do' => 'nodeForm', 'type' => $nodeType ] ), $nodeType::clubFrontTitle(), dataAttributes: [ 'data-ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'club_create_node' ) ], opensDialog: true ) );
				}
			}

			if( ( $this->owner and $this->owner->member_id == Member::loggedIn()->member_id ) or Member::loggedIn()->modPermission( 'can_access_all_clubs' ) or Member::loggedIn()->modPermission( 'can_manage_featured_clubs' ) )
			{
				$menu->addSeparator();
			}
		}

		if( $this->isLeader() )
		{
			$menu->add( new Link( $this->url()->setQueryString( 'do', 'editPhoto' ), 'club_profile_photo', dataAttributes: [
				'data-ipsDialog' => true,
				'data-ipsDialog-modal' => true,
				'data-ipsDialog-forceReload' => true,
				'data-ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'club_profile_photo' ),
				'data-action' => 'editPhoto'
			] ) );
		}

		if( ( $this->owner and $this->owner->member_id == Member::loggedIn()->member_id ) or Member::loggedIn()->modPermission( 'can_access_all_clubs' ) )
		{
			$menu->add( new Link( $this->url()->setQueryString( 'do', 'edit' ), 'club_edit_settings', opensDialog: true ) );
		}

		if( Member::loggedIn()->modPermission( 'can_manage_featured_clubs' ) )
		{
			if( $this->featured )
			{
				$menu->add( new Link( $this->url()->csrf()->setQueryString( 'do', 'unfeature' ), 'club_unfeature', dataAttributes: [ 'data-confirm' => '' ] ) );
			}
			else
			{
				$menu->add( new Link( $this->url()->csrf()->setQueryString( 'do', 'feature' ), 'club_feature', dataAttributes: [ 'data-confirm' => '' ] ) );
			}
		}

		if( $this->canInvite() )
		{
			$menu->add( new Link( $this->url()->setQueryString( 'do', 'invite' ), 'club_invite_members', dataAttributes: [ 'data-ipsDialog-size' => 'narrow' ], opensDialog: true ) );
		}

		if( $this->canManageNavigation() and count( $this->tabs( $container ) ) > 1 )
		{
			$menu->add( new Link( '#', 'reorder_club_menu', dataAttributes: [ 'data-action' => 'reorderClubmenu' ] ) );
		}

		if( ClubPage::canAdd( $this ) )
		{
			$menu->add( new Link( $this->url()->setQueryString( 'do', 'addPage' ), 'club_add_page', dataAttributes: [
				'data-ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'add_page_to_club', true, [ 'sprintf' => $this->name ] )
			], opensDialog: true ) );
		}

		if( $container instanceof ClubPage)
		{
			if( $container->canEdit() )
			{
				$menu->add( new Link( $container->url( 'edit' ), 'edit_page', opensDialog: true ) );
			}
			if( $container->canDelete() )
			{
				$menu->add( new Link( $container->url( 'delete' )->csrf(), 'delete_page', dataAttributes: [ 'data-confirm' => '' ] ) );
			}
		}

		if( $container instanceof Model and $this->isLeader() and $container->canLeaderManage() )
		{
			$menu->addSeparator();
			$menu->add( new Link( $this->url()->setQueryString( [ 'do' => 'nodeForm', 'type' => get_class( $container ), 'node' => $container->_id ] ), Member::loggedIn()->language()->addToStack( 'clubs_edit_this_container', true, [ 'sprintf' => Member::loggedIn()->language()->addToStack( $container::$nodeTitle . '_sg' ) ] ), dataAttributes: [ 'data-ipsDialog-title' => $container->_title ], opensDialog: true ) );

			if( isset( $container::$contentItemClass ) and $itemClass = $container::$contentItemClass and ( $container->modPermission( 'delete', Member::loggedIn(), $itemClass ) or !!$itemClass::contentCount( $container, true, true, true, 1 ) ) )
			{
				$menu->add( new Link( $this->url()->setQueryString( [ 'do' => 'nodeDelete', 'type' => get_class( $container ), 'node' => $container->_id] )->csrf(), Member::loggedIn()->language()->addToStack( 'clubs_delete_this_container', true, [ 'sprintf' => Member::loggedIn()->language()->addToStack( $container::$nodeTitle . '_sg' ) ] ), dataAttributes: [ 'data-confirm' => '', 'data-confirmSubMessage' => Member::loggedIn()->language()->addToStack( 'clubs_delete_container_confirm' ) ] ) );
			}
		}

		/* Extensions */
		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			foreach( $ext->menu( $this, $container ) as $link )
			{
				$menu->add( $link );
			}
		}

		return $menu;
	}

	/**
	 * Generate a buttons list for the club header
	 *
	 * @return Buttons
	 */
	public function buttons() : Buttons
	{
		$buttons = new Buttons();

		$memberStatus = $this->memberStatus( Member::loggedIn() );
		if( $this->type !== static::TYPE_PUBLIC )
		{
			if( $memberStatus === static::STATUS_BANNED )
			{
				$buttons->addButton( null, 'club_banned_title', icon: 'fa-solid fa-xmark', tooltip: 'club_banned_desc_short' );
			}
			elseif( in_array( $memberStatus, [ static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER, static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR, static::STATUS_WAITING_PAYMENT ] ) )
			{
				if( $memberStatus === static::STATUS_WAITING_PAYMENT )
				{
					$buttons->addButton( $this->url()->setQueryString( 'do', 'join' )->csrf(), 'club_pay_membership_fee' );
				}
				if( in_array( $memberStatus, [ static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ] ) )
				{
					$buttons->addButton( $this->url()->setQueryString( 'do', 'renew' )->csrf(), 'club_renew_membership' );
				}
				if( !$this->owner or $this->owner->member_id != Member::loggedIn()->member_id )
				{
					$attributes = [ 'data-confirm' => '' ];
					if( $this->isPaid() )
					{
						$attributes['data-confirmSubMessage'] = Member::loggedIn()->language()->addToStack( 'club_leave_paid_warning' );
					}
					$buttons->addButton( $this->url()->setQueryString( 'do', 'leave' )->csrf(), 'club_leave', dataAttributes: $attributes );
				}
			}
			elseif( $this->canJoin() )
			{
				/* We skip the join button for the sidebar view; it's shown somewhere else */
				if( Settings::i()->clubs_header != 'sidebar' )
				{
					$attributes = [];
					if( $this->isPaid() and $memberStatus !== static::STATUS_INVITED_BYPASSING_PAYMENT )
					{
						$attributes = [
							'data-confirm' => '',
							'data-confirmIcon' => 'info',
							'data-confirmMessage' => Member::loggedIn()->language()->addToStack( 'club_membership_item' ),
							'data-confirmSubmessage' => Member::loggedIn()->language()->addToStack( $this->memberFeeMessage() )
						];
					}
					$buttons->addButton( $this->url()->csrf()->setQueryString( 'do', 'join' )->addRef( Request::i()->url() ), 'club_join', dataAttributes: $attributes );
				}
			}
			elseif( !$this->canRead() )
			{
				if( $memberStatus === static::STATUS_REQUESTED )
				{
					$buttons->addButton( $this->url()->csrf()->setQueryString( 'do', 'cancelJoin' ), 'club_cancel_request', 'ipsButton ipsButton--text', icon: 'fa-solid fa-xmark' );
				}
				elseif( $memberStatus === static::STATUS_DECLINED )
				{
					$buttons->addButton( null, 'club_denied_title', dataAttributes: [ 'aria-disabled' => 'true' ], icon: 'fa-solid fa-xmark', tooltip: Member::loggedIn()->language()->addToStack( 'club_denied_desc_short' ) );
				}
			}
		}

		/* Extensions */
		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			foreach( $ext->buttons( $this ) as $button )
			{
				if( is_string( $button ) )
				{
					$buttons->addHtml( $button );
				}
				else
				{
					$buttons->add( $button );
				}
			}
		}

		return $buttons;
	}

	/**
	 * Return badges that should be displayed
	 *
	 * @return array
	 */
	public function badges() : array
	{
		$badges = [];

		if( Settings::i()->clubs_require_approval and !$this->approved )
		{
			$badges[] = new Icon( Badge::BADGE_WARNING, 'fa-eye-slash', Member::loggedIn()->language()->addToStack( 'club_unapproved' ) );
		}
		else
		{
			if( $this->featured )
			{
				$badges[] = new Icon( Badge::BADGE_POSITIVE, 'fa-star', Member::loggedIn()->language()->addToStack( 'featured' ) );
			}

			$memberStatus = $this->memberStatus( Member::loggedIn() );
			if( in_array( $memberStatus, [ static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER ] ) )
			{
				$badges[] = new Icon( Badge::BADGE_POSITIVE, 'fa-check', Member::loggedIn()->language()->addToStack( 'club_member' ) );
			}
			elseif( in_array( $memberStatus, [ static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ] ) )
			{
				$badges[] = new Icon( Badge::BADGE_INTERMEDIARY, 'fa-triangle-exclamation', Member::loggedIn()->language()->addToStack( 'club_expired' ) );
			}
			elseif( in_array( $memberStatus, [ static::STATUS_INVITED, static::STATUS_INVITED_BYPASSING_PAYMENT ] ) )
			{
				$badges[] = new Icon( Badge::BADGE_NEUTRAL, 'fa-envelope', Member::loggedIn()->language()->addToStack( 'club_invited' ) );
			}
			elseif( $memberStatus == static::STATUS_WAITING_PAYMENT )
			{
				$badges[] = new Icon( Badge::BADGE_NEUTRAL, 'fa-check', Member::loggedIn()->language()->addToStack( 'club_awaiting_payment_title' ) );
			}
			elseif( $memberStatus == static::STATUS_REQUESTED )
			{
				$badges[] = new Icon( Badge::BADGE_INTERMEDIARY, 'fa-clock', Member::loggedIn()->language()->addToStack( 'club_requested_desc_short' ) );
			}
		}

		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			/* @var ClubAbstract $ext */
			$badges = array_merge( $badges, $ext->badges( $this ) );
		}

		return $badges;
	}

	/**
	 * Can a member view the members page
	 *
	 * @param Member|null $member	The member (NULL for currently logged in member)
	 * @return bool
	 */
	public function canViewMembers( Member $member = NULL ): bool
	{
		$member = $member ?: Member::loggedIn();

		/* Public Clubs have no member list */
		if ( $this->type == Club::TYPE_PUBLIC )
		{
			return FALSE;
		}

		/* If NULL, everyone can view */
		if ( $this->show_membertab === NULL )
		{
			return TRUE;
		}

		/* Leader can see it always*/
		if (  $this->memberStatus( $member ) === Club::STATUS_LEADER )
		{
			return TRUE;
		}

		/* Moderator */
		if ( $this->show_membertab == 'moderator' AND ( $this->memberStatus( $member ) === Club::STATUS_MODERATOR ) )
		{
			return TRUE;
		}

		/* Members */
		if ( $this->show_membertab == 'member' AND in_array( $this->memberStatus( $member ), array( Club::STATUS_MEMBER, Club::STATUS_INVITED, Club::STATUS_INVITED_BYPASSING_PAYMENT, Club::STATUS_EXPIRED, Club::STATUS_EXPIRED_MODERATOR, Club::STATUS_MODERATOR ) ) )
		{
			return TRUE;
		}

		if ( $this->show_membertab == 'nonmember' )
		{
			return TRUE;
		}
		return FALSE;
	}

	/**
	 * Can be used by 3rd parties to add own club navigation tabs before they get sorted
	 *
	 * @param array $tabs	Tabs
	 * @param Model|ClubPage|null $container	Container
	 * @return array
	 */
	protected function _tabs( array $tabs, Model|ClubPage|null $container = NULL ) : array
	{
		foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
		{
			$app = explode( "\\", get_class( $ext ) )[1];
			foreach( $ext->tabs( $this, $container ) AS $key => $tab )
			{
				if ( $tab['show'] === TRUE )
				{
					$tabs[$app . '_' . $key] = $tab;
				}
			}
		}
		return $tabs;
	}

	/**
	 * Get the first tab for the club page
	 *
	 * @return array
	 */
	public function firstTab(): array
	{
		$tabs =  $this->tabs();
		reset( $tabs );

		$first = key( $tabs );

		return array( $first => $tabs[ $first ] );
	}

	/**
	 * Number of members to show per page
	 *
	 * @return int
	 */
	public function membersPerPage(): int
	{
		return 24;
	}

	/**
	 * Set navigational breadcrumbs
	 *
	 * @param Model $node	The node we are viewing
	 * @return	void
	 */
	public function setBreadcrumbs( Model $node ) : void
	{
		FrontNavigation::$clubTabActive = TRUE;

		Output::i()->breadcrumb = array();
		Output::i()->breadcrumb[] = array( Url::internal( 'app=core&module=clubs&controller=directory', 'front', 'clubs_list' ), Member::loggedIn()->language()->addToStack('module__core_clubs') );

		/* We have to prime the cache to ensure the correct club tab is selected */
		$this->tabs( $node );

		if( !( $firstTab = $this->firstTab() AND $firstTab = array_pop( $firstTab ) ) OR (string) $firstTab['href'] != (string) $node->url() )
		{
			Output::i()->breadcrumb[] = array( $this->url(), $this->name );
			Output::i()->breadcrumb[] = array( NULL, $node->_title );
		}
		else
		{
			Output::i()->breadcrumb[] = array( NULL, $this->name );
		}
		
		if ( Settings::i()->clubs_header == 'sidebar' )
		{
			Output::i()->sidebar['contextual'] = Theme::i()->getTemplate( 'clubs', 'core' )->header( $this, $node, 'sidebar' );
		}

		/* CSS */
		Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'styles/clubs.css', 'core', 'front' ) );

		/* JS */
		Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'front_clubs.js', 'core', 'front' ) );
	}

	/**
	 * Helper method to determine if the member is in a club
	 *
	 * @return bool
	 */
	public static function userIsInClub() : bool
	{
		return FrontNavigation::$clubTabActive or ( Dispatcher::i()->application->directory === 'core' and Dispatcher::i()->module->key === 'clubs' );
	}
	
	/* ! Rules */
	
	/**
	 * Rules have been acknowledged
	 *
	 * @param	Member|NULL		$member		Member to check, or NULL for currently logged in member.
	 * @return	bool
	 */
	public function rulesAcknowledged( ?Member $member = NULL ): bool
	{
		/* Rules must be acknowledged? */
		if ( !$this->rules_required )
		{
			return TRUE;
		}
		
		$member = $member ?: Member::loggedIn();
		
		/* Owners are exempt. */
		if ( $this->owner === $member )
		{
			return TRUE;
		}
		
		/* Leaders and Moderators are exempt. */
		if ( in_array( $this->memberStatus( $member ), array( static::STATUS_LEADER, static::STATUS_MODERATOR, static::STATUS_EXPIRED_MODERATOR ) ) )
		{
			return TRUE;
		}
		
		try
		{
			return (bool) Db::i()->select( 'rules_acknowledged', 'core_clubs_memberships', array( "club_id=? AND member_id=?", $this->id, $member->member_id ) )->first();
		}
		catch( UnderflowException $e )
		{
			/* If we can join the club return FALSE so we see the acknowledgement form. If we can't join, just return TRUE so the rules are returned but the user is not prompted to accept them. */
			return !$this->canJoin( $member );
		}
	}
	
	/**
	 * Acknowledge the rules
	 *
	 * @param	Member|NULL		$member		Member to set, or NULL for currently logged in member.
	 * @return	void
	 * @throws InvalidArgumentException
	 */
	public function acknowledgeRules( ?Member $member = NULL ) : void
	{
		$member = $member ?: Member::loggedIn();
		
		if ( $this->memberStatus( $member ) === NULL )
		{
			throw new InvalidArgumentException;
		}
		
		Db::i()->update( 'core_clubs_memberships', array( 'rules_acknowledged' => TRUE ), array( "club_id=? AND member_id=?", $this->id, $member->member_id ) );
	}

	/**
	 * Called when a club requiring moderation gets approved
	 * 
	 * @return void
	 */
	public function onApprove() : void
	{
		Webhook::fire( 'club_created', $this );
		Event::fire( 'onCreate', $this );
		$this->owner->achievementAction( 'core', 'NewClub', $this );

		try
		{
			Approval::loadFromContent( get_called_class(), $this->id )->delete();
		}
		catch( OutOfRangeException ){}
	}

	/**
	 * Embed Content
	 *
	 * @return	string
	 */
	public function embedContent(): string
	{
		return Theme::i()->getTemplate( 'clubs', 'core' )->embedClub( $this );
	}

	/**
	 * Get image for embed
	 *
	 * @return	File|NULL
	 */
	public function embedImage(): ?File
	{
		return $this->coverPhotoFile();
	}

	/**
	 * Update existing purchases
	 *
	 * @param	Purchase	$purchase							The purchase
	 * @param	array				$changes							The old values
	 * @param	bool				$cancelBillingAgreementIfNecessary	If making changes to renewal terms, TRUE will cancel associated billing agreements. FALSE will skip that change
	 * @return	void
	 */
	public function updatePurchase( Purchase $purchase, array $changes=array(), bool $cancelBillingAgreementIfNecessary=FALSE ) : void
	{
		$tax = NULL;
		if ( $purchase->tax )
		{
			try
			{
				$tax = Tax::load( $purchase->tax );
			}
			catch ( OutOfRangeException $e ) { }
		}

		$currency = $purchase->renewal_currency ?: $purchase->member->defaultCurrency( );

		$price = json_decode( $this->renewal_price, TRUE );

		$purchase->renewals = new RenewalTerm(
			new Money( $price[$currency]['amount'], $currency ),
			new DateInterval( 'P' . $this->renewal_term . mb_strtoupper( $this->renewal_units ) ),
			$tax
		);

		if ( $cancelBillingAgreementIfNecessary and $billingAgreement = $purchase->billing_agreement )
		{
			if ( array_key_exists( 'renewal_price', $changes ) and !empty( $changes['renewal_price'] ) )
			{
				try
				{
					$billingAgreement->cancel();
					$billingAgreement->save();
				}
				catch ( \Exception $e ) { }
			}
		}

		$purchase->save();
	}

	/**
	 * Iterate through all the club nodes and set the last_activity
	 *
	 * @param Member|null $member
	 * @return void
	 */
	public function updateLastActivityAndItemCount( Member $member = null ) : void
	{
		$member = $member ?: Member::loggedIn();
		$this->content = 0;
		$this->last_activity = 0;

		foreach ( $this->nodes() as $node )
		{
			try
			{
				$nodeClass = $node['node_class'];
				if ( ! class_exists( $nodeClass ) )
				{
					continue;
				}

				$node = $nodeClass::load( $node['node_id'] );

				if ( $lastCommentTime = $node->getLastCommentTime( $member ) and $lastCommentTime->getTimestamp() > $this->last_activity )
				{
					$this->last_activity = $lastCommentTime->getTimestamp();
				}

				$this->content += (int) $node->getContentItemCount();
			}
			catch ( \Exception $e ) { }
		}

		$this->rebuilt = time();
		$this->save();
	}

    /**
     * Follow this object
     *
     * @param string        $frequency      ( 'none', 'immediate', 'daily', 'weekly' )
     * @param bool          $public
     * @param Member|null   $member
     * @return void
     */
    public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void
    {
        $member = $member ?: Member::loggedIn();
        $this->_follow( $frequency, $public, $member );

        /* Follow all nodes */
        foreach( $this->nodes() as $node )
        {
            try
            {
                /* @var Model $class */
                $class = $node['node_class'];
                $class::load( $node['node_id'] )->follow( $frequency, $public, $member );
            }
            catch( OutOfRangeException ){}
        }
    }

    /**
     * Unfollow this object
     *
     * @param Member|null $member
     * @param string|null   $followId
     * @return void
     */
    public function unfollow( ?Member $member=null, ?string $followId=null ) : void
    {
        /* First run the main method so that we unfollow the club itself */
        $member = $member ?: Member::loggedIn();
        $this->_unfollow( $member, $followId );

        /* Now unfollow any nodes in the club */
        foreach( $this->nodes() as $node )
        {
            try
            {
                /* @var Model $class */
                $class = $node['node_class'];
                $class::load( $node['node_id'] )->unfollow( $member );
            }
            catch( OutOfRangeException ){}
        }
    }
}
