<?php
/**
 * @brief		Member 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		18 Feb 2013
 */

namespace IPS;

/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Member Model
 */
class _Member extends \IPS\Patterns\ActiveRecord
{
	/**
	 * @brief	Application
	 */
	public static $application = 'core';
	
	/* !\IPS\Patterns\ActiveRecord */
	
	/**
	 * @brief	[ActiveRecord] Multiton Store
	 */
	protected static $multitons;
	
	/**
	 * @brief	[ActiveRecord] Database Table
	 */
	public static $databaseTable = 'core_members';
		
	/**
	 * @brief	[ActiveRecord] ID Database Column
	 */
	public static $databaseColumnId = 'member_id';
	
	/**
	 * @brief	[ActiveRecord] Database ID Fields
	 */
	protected static $databaseIdFields = array( 'name', 'email', 'fb_uid', 'live_id', 'google_id', 'linkedin_id', 'ipsconnect_id', 'twitter_id' );
	
	/**
	 * @brief	[ActiveRecord] Multiton Map
	 */
	protected static $multitonMap	= array();
	
	/**
	 * @brief	Bitwise values for members_bitoptions field
	 */
	public static $bitOptions = array(
		'members_bitoptions'	=> array(
			'members_bitoptions'	=> array(
				'bw_is_spammer'					=> 1,			// Flagged as spam?
				// 2 is deprecated
				// 4 (bw_vnc_type) is deprecated
				// 8 (bw_forum_result_type) is deprecated
				'bw_no_status_update'			=> 16,			// Can post status updates?, 1 means they CAN'T post status updates. 0 means they can.
				// 32 (bw_status_email_mine) is deprecated
				// 64 (bw_status_email_all) is deprecated
				// 128 is deprecated (previously bw_disable_customization)
				// 256 is deprecated (bw_local_password_set) - used to represent if a local passwnot was set and block the change password form if not
				'bw_disable_tagging'			=> 512,			// Tags disabled for this member? 1 means they are, 0 means they aren't.
				'bw_disable_prefixes'			=> 1024,		// Tag prefixes disabled? 1 means they are, 0 means they aren't.
				'bw_using_skin_gen'				=> 2048,		// 1 means the user has the easy mode editor active, 0 means they do not.
				'bw_disable_gravatar'			=> 4096,		// If 0 then gravatar will not be used.
				// 8192 (bw_paste_plain) is deprecated
				// 16384 (bw_html_sig) is deprecated
				// 32768 (allow_admin_mails) is deprecated
				'view_sigs'						=> 65536,		// View signatures?
				// 131072 (view_img) is deprecated
				// 262144 is deprecated
				'coppa_user'					=> 524288,		// Was the member validated using coppa?
				// 1048576 (login_anonymous) is deprecated
				// 2097152 (login_anonymous_online) is deprecated
				// 4194304 (members_auto_dst) is deprecated
				// 8388608 (members_created_remote) is deprecated
				// 16777216 (members_disable_pm) is deprecated
				'unacknowledged_warnings'		=> 33554432,	// 1 means the member has at least one warning they have not acknowledged. 0 means they have none.
				// 67108864 (pp_setting_moderate_comments) is deprecated and replaced with global setting
				'pp_setting_moderate_followers'	=> 134217728,	// Previously pp_setting_moderate_friends. Replaced with setting that toggles whether or not member can be followed. 
				'pp_setting_count_visitors'		=> 268435456,	// If TRUE, last 5 visitors will be shown on profile
				'timezone_override'				=> 536870912,	// If TRUE, user's timezone will not be detected automatically
				'validating'					=> 1073741824,	// If TRUE user is validating and should have a corresponding row in core_validating
			),
			'members_bitoptions2'	=> array(
				'show_pm_popup'					=> 1, // "Show pop-up when I have a new message"
				'remove_gallery_access'			=> 2, // Remove access to Gallery
				'remove_gallery_upload'			=> 4, // Remove permission to upload images in Gallery
				'no_report_count'				=> 8, // 1 means the report count will not show
				'has_no_ignored_users'			=> 16, // If we know the user has no ignored users, we don't have to query for them
				'must_reaccept_privacy'			=> 32, // 1 means the member needs to re-accept the privacy policy
				'must_reaccept_terms'			=> 64, // 1 means the member needs to re-accept the registration terms
				'email_notifications_once'      => 128, // 1 means the member only wants one email per notification item until they revisit the community
				'disable_notification_sounds'	=> 256, // 1 means notification sounds are disabled, 0 is enabled
				'has_security_answers'			=> 512,
				'security_questions_opt_out'	=> 1024,
				'ignore_promotions'				=> 2048,// 1 means that the user will not be checked against group promotion rules. Set by Commerce to prevent "promoting" users incorrectly after a purchase.
				'profile_completed'				=> 4096,
				'profile_completion_dismissed'	=> 8192

			)
		)
	);
	
	/* !Follow */
	
	/**
	 * @brief	Cache for current follow data, used on "My Followed Content" screen
	 */
	public $_followData;
	
	const FOLLOW_PUBLIC = 1;
	const FOLLOW_ANONYMOUS = 2;
	
	/**
	 * @brief	Cached logged in member
	 */
	public static $loggedInMember	= NULL;
	
	/**
	 * @brief	If we change the photo_type, then we need to record the previous photo type to determin if set_pp_main_photo should attempt removal of existing images
	 */
	protected $_previousPhotoType = NULL;

	/**
	 * Get logged in member
	 *
	 * @return	\IPS\Member
	 */
	public static function loggedIn()
	{
		/* If we haven't loaded the member yet, or if the session member has changed since we last loaded the member, reload and cache */
		if( static::$loggedInMember === NULL )
		{
			static::$loggedInMember = \IPS\Session::i()->member;
			
			if ( isset( $_SESSION['logged_in_as_key'] ) )
			{
				if ( static::$loggedInMember->isAdmin() AND static::$loggedInMember->hasAcpRestriction( 'core', 'members', 'member_login' ) )
				{
					$key = $_SESSION['logged_in_as_key'];
	
					if ( isset( \IPS\Data\Store::i()->$key ) )
					{
						static::$loggedInMember	= static::load( \IPS\Data\Store::i()->$key );
					}
				}
			}
			
			if ( !static::$loggedInMember->member_id and isset( \IPS\Request::i()->cookie['ipsTimezone'] ) )
			{
				static::$loggedInMember->timezone = \IPS\Request::i()->cookie['ipsTimezone'];
			}
		}

		return static::$loggedInMember;
	}
	
	/**
	 * Load Record
	 * We override it so we return a guest object for a non-existant member
	 *
	 * @see		\IPS\Db::build
	 * @param	int|string	$id					ID
	 * @param	string		$idField			The database column that the $id parameter pertains to (NULL will use static::$databaseColumnId)
	 * @param	mixed		$extraWhereClause	Additional where clause (see \IPS\Db::build for details)
	 * @return	static
	 */
	public static function load( $id, $idField=NULL, $extraWhereClause=NULL )
	{
		try
		{
			if( $id === NULL OR $id === 0 OR $id === '' )
			{
				$classname = get_called_class();
				return new $classname;
			}
			else
			{
				$member = parent::load( $id, $idField, $extraWhereClause );
				
				if ( $member->restrict_post > 0 and $member->restrict_post <= time() )
				{
					$member->restrict_post = 0;
					$member->save();
				}

				return $member;
			}
		}
		catch ( \OutOfRangeException $e )
		{
			$classname = get_called_class();
			return new $classname;
		}
	}
	
	/**
	 * Load record based on a URL
	 *
	 * @param	\IPS\Http\Url	$url	URL to load from
	 * @return	static
	 * @throws	\InvalidArgumentException
	 * @throws	\OutOfRangeException
	 */
	public static function loadFromUrl( \IPS\Http\Url $url )
	{
		$member = parent::loadFromUrl( $url );
		if ( !$member->member_id )
		{
			throw new \OutOfRangeException;
		}
		return $member;
	}
	
	/**
	 * Set Default Values
	 *
	 * @return	void
	 */
	public function setDefaultValues()
	{
		/* If we're in the installer - don't do this */
		if ( \IPS\Dispatcher::hasInstance() and \IPS\Dispatcher::i()->controllerLocation === 'setup' )
		{
			return;
		}

		$this->member_group_id		= \IPS\Settings::i()->guest_group;
		$this->mgroup_others		= '';
		$this->joined				= time();
		$this->ip_address			= \IPS\Request::i()->ipAddress();
		$this->timezone				= 'UTC';
		$this->allow_admin_mails	= 1;
		$this->pp_photo_type        = '';
		$this->member_posts 		= 0;
		$this->last_visit			= NULL;
		$this->_data['pp_main_photo'] = NULL;
		$this->_data['pp_thumb_photo'] = NULL;
		$this->_data['failed_logins'] = NULL;
		$this->_data['mfa_details'] = NULL;
		$this->_data['pp_reputation_points'] = 0;
		$this->_data['signature'] = '';
		$this->_data['auto_track']	= json_encode( array(
			'content'	=> \IPS\Settings::i()->auto_follow_new_content ? 1 : 0,
			'comments'	=> \IPS\Settings::i()->auto_follow_replied_to ? 1 : 0,
			'method'	=> 'immediate'
		)	);

		if( isset( \IPS\Request::i()->cookie['language'] ) AND \IPS\Request::i()->cookie['language'] )
		{
			$this->language	= \IPS\Request::i()->cookie['language'];
		}
		
		if( isset( \IPS\Request::i()->cookie['theme'] ) AND \IPS\Request::i()->cookie['theme'] )
		{
			$this->skin	= \IPS\Request::i()->cookie['theme'];
		}
	}

	/**
	 * [ActiveRecord] Delete Record
	 *
	 * @param bool $setAuthorToGuest	Sets the author id of all content to 0 ( guest )
	 * @param bool $keepAuthorName		Keeps the author name
	 * @return void
	 */
	public function delete( $setAuthorToGuest = TRUE, $keepAuthorName = TRUE )
	{
        if( $setAuthorToGuest )
        {
            /* Clean up content - set to member ID 0 - We check $setAuthorToGuest because of member merging.
            As the member is immediately deleted we do not want to compete with the existing merge in progress. */
            $this->hideOrDeleteAllContent( 'merge', array( 'merge_with_id' => 0, 'merge_with_name' => $keepAuthorName  ?  $this->name : '' ) );
        }

		/* Let apps do their stuff */
		$this->memberSync( 'onDelete' );
		
		/* Actually delete from database */
		parent::delete();
		
		/* Login handler callback */
		foreach ( \IPS\Login::handlers( TRUE ) as $k => $handler )
		{
			try
			{
				$handler->deleteAccount( $this );
			}
			catch( \Exception $e ){}
		}
		
		/* Reset statistics */
		\IPS\Widget::deleteCaches();
	}

	/**
	 * [ActiveRecord] Save Changed Columns
	 *
	 * @return	void
	 * @note	We have to be careful when upgrading in case we are coming from an older version
	 */
	public function save()
	{
		if ( $this->member_id AND ( !\IPS\Dispatcher::hasInstance() OR \IPS\Dispatcher::i()->controllerLocation != 'setup' ) )
		{
			$this->checkGroupPromotion();
		}

		$new		= $this->_new;
		$changes	= $this->changed;

		parent::save();

		if ( $new )
		{
			$this->memberSync( 'onCreateAccount' );

			/* Login handler callback */
			foreach ( \IPS\Login::handlers( TRUE ) as $k => $handler )
			{
				try
				{
					$handler->createAccount( $this );
				}
				catch( \Exception $e ){}
			}
						
			/* Profile Fields */
			\IPS\Db::i()->insert( 'core_pfields_content', array( 'member_id' => $this->member_id ), TRUE );
		}
		else
		{
			/* Run member sync, but not if the only change is the last_activity timestamp */
			if( count( $this->changedCustomFields ) > 0 or count( $changes ) > 1 OR !isset( $changes['last_activity'] ) )
			{
				$this->memberSync( 'onProfileUpdate', array( 'changes' => array_merge( $changes, $this->changedCustomFields ) ) );
			}
		}

		/* If we have updated custom fields, make sure we don't have any cached in the class - just wipe out cache so we will refetch if needed */
		if( count( $this->changedCustomFields ) )
		{
			$this->rawProfileFieldsData	= NULL;
			$this->profileFields		= NULL;
		}

		/* Check Changes for Member History */
		$this->logChanges( $changes, $new );

		/* rebuild create menu if the user changed status settings or group(s) */
		if ( !$new AND ( isset( $changes['pp_setting_count_comments'] ) or isset( $changes['member_group_id'] ) or isset( $changes['mgroup_others'] ) ) )
		{
			$this->create_menu = NULL;
			parent::save();
		}
	}

	/**
	 * Log changes from the save method
	 *
	 * @param	array		$changes
	 * @return	void
	 */
	protected function logChanges( $changes, $new=FALSE )
	{
		$logTypes = array( 'member_group_id', 'mgroup_others', 'name', 'linkedin_id', 'fb_uid', 'google_id', 'live_id', 'twitter_id', 'ipsconnect_id' );

		foreach( $changes as $field => $value )
		{
			if( !in_array( $field, $logTypes ) )
			{
				continue;
			}

			/* Don't log anything if it's a new account, except if it's a social service connection */
			if( $new and !in_array( $field, array( 'linkedin_id', 'fb_uid', 'google_id', 'live_id', 'twitter_id', 'ipsconnect_id' ) ) )
			{
				continue;
			}
			
			switch( $field )
			{
				case 'member_group_id':
					if( $this->_groupPromotion === NULL )
					{
						$this->logHistory( 'core', $field, array( 'new' => $value ) );
					}
					else
					{
						$this->logHistory( 'core', 'group_promotion', array( 'reason' => $this->_groupPromotion, 'new' => $value ) );
					}
					break;

				case 'mgroup_others':
					if( $this->_groupPromotion === NULL )
					{
						$this->logHistory( 'core', $field, array( 'new' => $value ) );
					}
					else
					{
						$this->logHistory( 'core', 'group_promotion_o', array( 'reason' => $this->_groupPromotion, 'new' => $value ) );
					}
					break;
				case 'name':
					$this->logHistory( 'core', 'display_name', array( 'old' => $this->previousName, 'new' => $value ) );
					break;
				case 'linkedin_id':
				case 'fb_uid':
				case 'google_id':
				case 'live_id':
				case 'twitter_id':
				case 'ipsconnect_id':
					/* expand the name so it can be used later for language lookups */
					if( $field == 'fb_uid' )
					{
						$field = 'facebook';
					}
					
					$data = array( 'service' => str_replace( array( '_uid', '_id' ), '', $field ), 'linked' => ( (bool) $value ) );

					if( $value and $new )
					{
						$data['registered'] = TRUE;
					}

					$this->logHistory( 'core', 'social_account', $data );
					break;
				default:
					$this->logHistory( 'core', $field, array( 'new' => $value ) );
					break;
			}
		}
	}
	
	/* !Getters/Setters Data */
	
	/**
	 * Group Data, taking into consideration secondary groups
	 */
	public $_group = NULL;
	
	/**
	 * @brief	Admin CP Restrictions
	 */
	protected $restrictions = NULL;
	
	/**
	 * @brief	Moderator Permissions
	 */
	protected $modPermissions = NULL;
	
	/**
	 * @brief	Calculated language ID
	 */
	protected $calculatedLanguageId = NULL;
	
	/**
	 * @brief	Marker Cache
	 */
	public $markers = array();
	protected $markersResetTimes = array();
	protected $haveAllMarkers = FALSE;
	
	/**
	 * @brief	Default stream ID
	 */
	protected $defaultStreamId = FALSE;
	
	/**
	 * @brief	Keep track of any changed profile fields
	 */
	public $changedCustomFields = array();

	/**
	 * Get name, do not return "guest" name if not set
	 *
	 * @return	string
	 */
	public function get_real_name()
	{
		return ( isset( $this->_data['name'] ) ) ? $this->_data['name'] : '';
	}

	/**
	 * Get name
	 *
	 * @return	string
	 */
	public function get_name()
	{
		if( !isset( $this->_data['name'] ) )
		{
			return \IPS\Member::loggedIn()->language()->addToStack('guest');
		}
		
		return $this->member_id ? $this->_data['name'] : \IPS\Member::loggedIn()->language()->addToStack( 'guest_name_shown', NULL, array( 'sprintf' => array( $this->_data['name'] ) ) );
	}

	/**
	 * @brief	Previous name - stored temporarily for display name history log
	 */
	protected $previousName	= NULL;

	/**
	 * Set name
	 *
	 * @param	string	$value	Value
	 * @return	void
	 */
	public function set_name( $value )
	{
		if( isset( $this->_data['name'] ) )
		{
			$this->previousName				= $this->_data['name'];
		}

		$this->_data['name']				= $value;
		$this->_data['members_seo_name']	= \IPS\Http\Url\Friendly::seoTitle( $value );

		if( $this->_data['pp_photo_type'] == 'letter' )
		{
			$this->deletePhoto();
			$this->_data['pp_photo_type']	= '';
			$this->_data['pp_main_photo']	= NULL; 
			$this->_data['pp_thumb_photo']	= NULL;
		}
	}

	/**
	 * Set group
	 *
	 * @see		\IPS\Patterns\ActiveRecord::__set
	 * @param	int	$value	Value
	 * @return	void
	 */
	public function set_member_group_id( $value )
	{
		$this->_data['member_group_id'] = (int) $value;
		$this->_group = NULL;
	}
	
	/**
	 * Set Secondary Groups
	 *
	 * @see		\IPS\Patterns\ActiveRecord::__set
	 * @param	string	$value	Value
	 * @return	void
	 */
	public function set_mgroup_others( $value )
	{
		$groups = array_filter( explode( ",", $value ) );
		if ( in_array( \IPS\Settings::i()->guest_group, $groups ) )
		{
			throw new \InvalidArgumentException;
		}
				
		$this->_data['mgroup_others'] = implode( ',', $groups );
		$this->_group = NULL;
	}
		
	/**
	 * Flag as spammer
	 *
	 * @return	void
	 */
	public function flagAsSpammer()
	{
		if ( !$this->members_bitoptions['bw_is_spammer'] )
		{
			$actions = explode( ',', \IPS\Settings::i()->spm_option );
						
			/* Hide or delete */
			if ( in_array( 'unapprove', $actions ) or in_array( 'delete', $actions ) )
			{
				/* Send to queue */
				$this->hideOrDeleteAllContent( in_array( 'delete', $actions ) ? 'delete' : 'hide' );
				
				/* Clear out their profile */
				if ( in_array( 'delete', $actions ) )
				{
					$this->member_title			= '';
					$this->signature		= '';
					$this->pp_main_photo	= NULL;
					
					\IPS\Db::i()->delete( 'core_pfields_content', array( 'member_id=?', $this->member_id ) );
				}
			}
			
			/* Restrict from posting or ban */
			if ( in_array( 'disable', $actions ) or in_array( 'ban', $actions ) )
			{
				if ( in_array( 'ban', $actions ) )
				{
					$this->temp_ban = -1;
				}
				else
				{
					$this->restrict_post = -1;
					$this->members_disable_pm = 2;
				}
			}
									
			/* Save */
			$this->members_bitoptions['bw_is_spammer'] = TRUE;
			$this->save();

			/* Run sync */
			$this->memberSync( 'onSetAsSpammer' );
			
			/* Notify admin */
			if ( \IPS\Settings::i()->spm_notify )
			{
				\IPS\Email::buildFromTemplate( 'core', 'admin_spammer', array( $this ), \IPS\Email::TYPE_LIST )->send( \IPS\Settings::i()->email_in );
			}
			
			/* Feedback to Spam Monitoring Service */
			if ( \IPS\Settings::i()->spam_service_enabled and \IPS\Settings::i()->spam_service_send_to_ips )
			{
				$this->spamService( 'markspam' );
			}
		}
	}
	
	/**
	 * Hide/Delete All Content
	 *
	 * @param	string	$action	'hide' or 'delete' or 'merge'
	 * @param	array 	$extra	Extra data needed by the MemberContent plugin
	 * @return	void
	 */
	public function hideOrDeleteAllContent( $action, $extra=array() )
	{
		/* Edited member, so clear widget caches (stats, widgets that contain photos, names and so on) */
		\IPS\Widget::deleteCaches();

		/* Send to the queue, include archived content */
		foreach ( \IPS\Content::routedClasses( FALSE, TRUE, FALSE ) as $class )
		{
			if ( isset( $class::$databaseColumnMap['author'] ) and ( $action == 'delete' or in_array( 'IPS\Content\Hideable', class_implements( $class ) ) ) )
			{
				\IPS\Task::queue( 'core', 'MemberContent', array_merge( array( 'initiated_by_member_id' => \IPS\Member::loggedIn()->member_id, 'member_id' => $this->member_id, 'name' => $this->name, 'class' => $class, 'action' => $action ), $extra ), 1 );
			}
		}

		/* And private messages */
		\IPS\Task::queue( 'core', 'MemberContent', array_merge( array( 'initiated_by_member_id' => \IPS\Member::loggedIn()->member_id, 'member_id' => $this->member_id, 'name' => $this->name, 'class' => 'IPS\\core\\Messenger\\Conversation', 'action' => $action ), $extra ), 1 );
	}
	
	/**
	 * Unflag as spammer
	 *
	 * @return	void
	 */
	public function unflagAsSpammer()
	{
		if ( $this->members_bitoptions['bw_is_spammer'] )
		{
			/* Save */
			$this->members_bitoptions['bw_is_spammer'] = FALSE;
			$this->save();

			/* Remove any pending hide or delete content queued tasks */
			foreach( \IPS\Db::i()->select( '*', 'core_queue', array( '`key`=?', 'MemberContent' ) ) as $task )
			{
				$data = json_decode( $task['data'], true );

				if( $data['member_id'] == $this->member_id )
				{
					\IPS\Db::i()->delete( 'core_queue', array( 'id=?', $task['id'] ) );
				}
			}
			
			/* Report back to spam service */
			if ( \IPS\Settings::i()->spam_service_enabled and \IPS\Settings::i()->spam_service_send_to_ips )
			{
				$this->spamService( 'notspam' );
			}

			/* Run sync */
			$this->memberSync( 'onUnSetAsSpammer' );
		}
	}

	/**
	 * Get auto-track data
	 *
	 * @return	array
	 */
	public function get_auto_follow()
	{
		return ( mb_substr( $this->_data['auto_track'], 0, 1 ) !== '{' ) ?
			array( 'method' => 'immediate', 'content' => 0, 'comments' => (int) $this->_data['auto_track'] ) :
			json_decode( $this->_data['auto_track'], TRUE );
	}
	
	/**
	 * Set banned
	 *
	 * @param	string	$value	Value
	 * @return	void
	 */
	public function set_temp_ban( $value )
	{
		$this->_data['temp_ban'] = $value;
		if ( $value == -1 )
		{
			\IPS\Db::i()->delete( 'core_validating', array( 'member_id=?', $this->member_id ) );
		}
		else
		{
			$this->members_bitoptions['validating'] = FALSE;
		}
	}
	
	/**
	 * Get Group Data
	 *
	 * @return	array
	 */
	public function get_group()
	{
		if ( $this->_group === NULL )
		{
			/* Load primary group */
			try
			{
				$group = \IPS\Member\Group::load( $this->_data['member_group_id'] );
			}
			catch ( \OutOfRangeException $e )
			{
				$group = \IPS\Member\Group::load( \IPS\Settings::i()->member_group );
			}
			
			$this->_group = array_merge( $group->data(), $group->g_bitoptions->asArray() );

			/* Merge in secondary group data */
			if ( !empty( $this->_data['mgroup_others'] ) )
			{
				$groups			= array_filter( explode( ',', $this->_data['mgroup_others'] ) );
				$exclude		= array();
				$lessIsMore		= array();
				$neg1IsBest		= array();
				$zeroIsBest		= array();
				$callback		= array();
	
				/* Get the limits we need to work out from apps */
				foreach ( \IPS\Application::allExtensions( 'core', 'GroupLimits', FALSE, 'core' ) as $key => $extension )
				{
					if( method_exists( $extension, 'getLimits' ) )
					{
						$appLimits = $extension->getLimits();
						
						if( !empty( $appLimits['neg1IsBest'] ) )
						{
							$neg1IsBest	= array_merge( $neg1IsBest, $appLimits['neg1IsBest'] );
						}
							
						if( !empty( $appLimits['zeroIsBest'] ) )
						{
							$zeroIsBest = array_merge( $zeroIsBest, $appLimits['zeroIsBest'] );
						}
							
						if( !empty( $appLimits['lessIsMore'] ) )
						{
							$lessIsMore	= array_merge( $lessIsMore, $appLimits['lessIsMore'] );
						}
							
						if( !empty( $appLimits['exclude'] ) )
						{
							$exclude = array_merge( $exclude, $appLimits['exclude'] );
						}
						
						if( !empty( $appLimits['callback'] ) )
						{
							$callback = array_merge( $callback, $appLimits['callback'] );
						}
					}
				}
				
				/* Do the merging */
				$skippedGroups	= array();
	
				foreach( $groups as $gid )
				{
					try
					{
						$group = \IPS\Member\Group::load( $gid );
					}
					catch( \OutOfRangeException $e )
					{
						$skippedGroups[]	= $gid;
						continue;
					}
	
					$_data = array_merge( $group->_data, $group->g_bitoptions->asArray() );
	
					foreach( $_data as $k => $v )
					{
						if ( ! in_array( $k, $exclude ) )
						{
							if ( in_array( $k, $zeroIsBest ) )
							{
								if ( empty( $this->_group[ $k ] ) )
								{
									continue;
								}
								else if( $v == 0 )
								{
									$this->_group[ $k ] = 0;
								}
								else if ( $v > $this->_group[ $k ] )
								{
									$this->_group[ $k ] = $v;
								}
							}
							else if( in_array( $k, $neg1IsBest ) )
							{
								
								if ( $this->_group[ $k ] == -1 )
								{
									continue;
								}
								else if( $v == -1 )
								{
									$this->_group[ $k ] = -1;
								}
								else if ( $v > $this->_group[ $k ] )
								{
									$this->_group[ $k ] = $v;
								}
							}
							else if ( in_array( $k, $lessIsMore ) )
							{
								if ( $v < $this->_group[ $k ] )
								{
									$this->_group[ $k ] = $v;
								}
							}
							else if ( array_key_exists( $k, $callback ) )
							{
								$result = call_user_func( $callback[ $k ], $this->_group, $_data, $k, $this->_data );
	
								if( is_array( $result ) )
								{
									$this->_group	= array_merge( $this->_group, $result );
								}
								else if( $result !== NULL )
								{
									$this->_group[ $k ]	= $result;
								}
							}
							else
							{
								if ( !isset( $this->_group[ $k ] ) OR $v > $this->_group[ $k ] )
								{
									$this->_group[ $k ] = $v;
								}
							}
						}
					}
				}
	
				if( count( $skippedGroups ) )
				{
					$this->mgroup_others = implode( ',', array_diff( $groups, $skippedGroups ) );
					
					parent::save();
				}
			}
		}

		return $this->_group;
	}

	/**
	 * Retrieve the group name
	 *
	 * @return string
	 */
	public function get_groupName()
	{
		if ( $this->_group === NULL )
		{
			$group = $this->group;
		}

		if( $this->_data['member_group_id'] )
		{
			$group = \IPS\Member\Group::load( $this->_data['member_group_id'] );
			$this->_group['name'] = $group->formatName( \IPS\Member::loggedIn()->language()->addToStack( "core_group_{$group->g_id}" ) );
		}

		return $this->_group['name'];
	}

	/**
	 * @brief	Cached groups check
	 */
	protected $_groups = NULL;
	
	/**
	 * Get an array of the group IDs (including secondary groups) this member belongs to
	 *
	 * @return	array
	 */
	public function get_groups()
	{
		if ( count( $this->_groups ) )
		{
			return $this->_groups;
		}
		
		$this->_groups = array( $this->_data['member_group_id'] );

		if( $this->_data['mgroup_others'] )
		{
			foreach( explode( ',', $this->_data['mgroup_others'] ) as $id )
			{
				$this->_groups[] = intval( $id );
			}
		}
		
		/* Sort for consistency when using permissions as part of a cache key */
		sort( $this->_groups, SORT_NUMERIC );
	
		return $this->_groups;
	}
	
	/**
	 * Social Groups
	 */
	protected $_socialGroups = NULL;
	
	/**
	 * Social Groups
	 *
	 * @param	bool	$fromWriteServer	If Read/Write separation is enabled, this flag can be used to force reading from the write server, which can be used when rebuilding cached permission strings
	 * @return	array
	 */
	public function socialGroups( $fromWriteServer = FALSE )
	{
		if ( $this->_socialGroups === NULL )
		{
			/* If this is a guest, they will not have any social groups - save the query */
			if( !$this->member_id )
			{
				$this->_socialGroups = array();
			}
			else
			{
				$this->_socialGroups = iterator_to_array( \IPS\Db::i()
					->select( 'group_id', 'core_sys_social_group_members', array( 'member_id=?', $this->member_id ), NULL, NULL, NULL, NULL, $fromWriteServer ? \IPS\Db::SELECT_FROM_WRITE_SERVER : 0 )
					->setKeyField( 'group_id' )
					->setValueField( 'group_id' ) );
			}
		}
		return $this->_socialGroups;
	}
	
	/**
	 * Clubs
	 */
	protected $_clubs = NULL;
	
	/**
	 * Clubs
	 *
	 * @param	bool	$fromWriteServer	If Read/Write separation is enabled, this flag can be used to force reading from the write server, which can be used when rebuilding cached permission strings
	 * @return	array
	 */
	public function clubs( $fromWriteServer = FALSE )
	{
		if ( !\IPS\Settings::i()->clubs )
		{
			return array();
		}
		
		if ( $this->_clubs === NULL )
		{
			/* If this is a guest, they will not have any clubs - save the query */
			if( !$this->member_id )
			{
				$this->_clubs = array();
			}
			else
			{
				$this->_clubs = iterator_to_array( \IPS\Db::i()->select( 'club_id', 'core_clubs_memberships', array( "member_id=? AND status IN('" . \IPS\Member\Club::STATUS_MEMBER . "','" . \IPS\Member\Club::STATUS_MODERATOR . "','" . \IPS\Member\Club::STATUS_LEADER . "')", $this->member_id ), NULL, NULL, NULL, NULL, $fromWriteServer ? \IPS\Db::SELECT_FROM_WRITE_SERVER : 0 ) );
			}
		}
		return $this->_clubs;
	}
	
	/**
	 * Permission Array
	 *
	 * @return	array
	 */
	public function permissionArray()
	{
		$return = $this->groups;
		
		if ( $this->member_id )
		{
			$return[] = "m{$this->member_id}";
			if ( \IPS\Settings::i()->clubs )
			{
				$return[] = "ca"; // Public clubs, which is everyone except guests
			}
		
			if ( $this->permission_array === NULL )
			{			
				$this->rebuildPermissionArray();
			}
			
			if( $this->permission_array )
			{
				$return = array_merge( $return, explode( ',', $this->permission_array ) );
			}
		}
				
		return $return;
	}
	
	/**
	 * Permission Array
	 *
	 * @return	void
	 */
	public function rebuildPermissionArray()
	{
		$permissionArray = array();
		foreach ( $this->socialGroups( TRUE ) as $socialGroupId )
		{
			$permissionArray[] = "s{$socialGroupId}";
		}
		if ( \IPS\Settings::i()->clubs )
		{
			/* Wipe club cache as when we are added, perm is rebuilt but the new club membership is not detected as we are using cached value */
			$this->_clubs = NULL;
			foreach ( $this->clubs( TRUE ) as $clubId )
			{
				$permissionArray[] = "c{$clubId}";
			}
			if ( $this->modPermission('can_access_all_clubs') )
			{
				$permissionArray[] = 'cm';
			}
		}
	
		$this->permission_array = implode( ',', $permissionArray );
		$this->save();
	}
	
	/**
	 * Get Joined Date
	 *
	 * @return	\IPS\DateTime
	 */
	public function get_joined()
	{
		return \IPS\DateTime::ts( $this->_data['joined'] );
	}
	
	/**
	 * Get Photo Type
	 *
	 * @return	string
	 */
	public function get_pp_photo_type()
	{
		if ( !$this->_data['pp_photo_type'] and \IPS\Settings::i()->allow_gravatars and $this->member_id and !$this->members_bitoptions['bw_disable_gravatar'] )
		{
			return 'gravatar';
		}
		return $this->_data['pp_photo_type'];
	}
	
	/**
	 * Get SEO Name
	 *
	 * @return	string
	 */
	public function get_members_seo_name()
	{
		/* Set it so it will be saved */
		if( !isset( $this->_data['members_seo_name'] ) or !$this->_data['members_seo_name'] )
		{
			if ( !$this->name )
			{
				return NULL;
			}
			
			$this->members_seo_name	= \IPS\Http\Url\Friendly::seoTitle( $this->name );
		}

		return $this->_data['members_seo_name'] ?: \IPS\Http\Url\Friendly::seoTitle( $this->name );
	}

	/**
	 * Get localized birthday, taking into account optional year
	 *
	 * @return	string|null
	 */
	public function get_birthday()
	{
		try
		{
			if( $this->_data['bday_year'] )
			{
				$date	= new \IPS\DateTime( str_pad( $this->_data['bday_year'], 4, 0, STR_PAD_LEFT ) . str_pad( $this->_data['bday_month'], 2, 0, STR_PAD_LEFT ) . str_pad( $this->_data['bday_day'], 2, 0, STR_PAD_LEFT ) );

				return $date->fullYearLocaleDate();
			}
			else if( $this->_data['bday_month'] )
			{
				$date	= new \IPS\DateTime( $this->_data['bday_month'] . '/' . $this->_data['bday_day'] );

				return $date->dayAndMonth();
			}
			else
			{
				return NULL;
			}
		}
		catch ( \Exception $e )
		{
			\IPS\Log::debug( "Member " . $this->id ." has a not valid birthday date" , 'birthday_error' );
			return NULL;
		}
	}

	/**
	 * Get the member's age
	 *
	 * @param	\IPS\DateTime|null	$date	If supplied, birthday is calculated from this point
	 * @note	If the member has not specified a birth year (which is optional), NULL is returned
	 * @return	int|null
	 */
	public function age( $date=NULL )
	{
		if( $this->_data['bday_year'] AND checkdate( $this->_data['bday_month'], $this->_data['bday_day'], $this->_data['bday_year'] ) )
		{
			/* We use dashes because DateTime accepts two digit years with it */
			$birthday	= new \IPS\DateTime( $this->_data['bday_year'] . '-' . $this->_data['bday_month'] . '-' . $this->_data['bday_day'] );
			if ( \IPS\Member::loggedIn()->timezone )
			{
				$birthday->setTimezone( new \DateTimeZone( \IPS\Member::loggedIn()->timezone ) );
			}

			$today = $date ? clone $date : new \IPS\DateTime();
			$today->setTime( 23, 59, 59 ); // We want how old they'll be at the end of the provided date
			if ( \IPS\Member::loggedIn()->timezone )
			{
				$today->setTimezone( new \DateTimeZone( \IPS\Member::loggedIn()->timezone ) );
			}

			return $birthday->diff( $today )->y;
		}
		else
		{
			return NULL;
		}
	}
			
	/**
	 * User's photo URL
	 *
	 * @param	bool	$thumb	Use thumbnail?
	 * @param	bool	$email	Is the photo going to be used in an email?
	 * @return string
	 */
	public function get_photo( $thumb=TRUE, $email=FALSE )
	{
		return static::photoUrl( $this->_data, $thumb, $email );
	}

	/**
	 * Set Photo Type
	 *
	 * @param	string	$type	Photo type
	 * @return	void
	 */
	public function set_pp_photo_type( $type )
	{
		if ( $this->_previousPhotoType === NULL and isset( $this->_data['pp_photo_type'] ) )
		{
			$this->_previousPhotoType = $this->_data['pp_photo_type'];
		}
		$this->_data['pp_photo_type'] = $type;
	}
	
	/**
	 * Set Photo
	 *
	 * @param	string	$photo	Photo location
	 * @return	void
	 */
	public function set_pp_main_photo( $photo )
	{
		$this->deletePhoto();
		
		$this->_data['pp_main_photo'] = $photo;
	}

	/**
	 * Delete the profile photo
	 *
	 * @return void
	 */
	public function deletePhoto()
	{
		/* It is common to update pp_photo_type before pp_main_photo */
		$photoType = ( $this->_previousPhotoType !== NULL ) ? $this->_previousPhotoType : $this->_data['pp_photo_type'];
		
		/* Attempt to delete existing images if they are from a profile sync, or uploaded/imported from URL */
		if ( mb_substr( $photoType, 0, 5 ) === 'sync-' or $photoType === 'custom' or $photoType === 'letter' )
		{
			if ( $this->_data['pp_main_photo'] )
			{
				try
				{
					\IPS\File::get( 'core_Profile', $this->_data['pp_main_photo'] )->delete();
				}
				catch ( \Exception $e ) {}
			}
			if ( $this->_data['pp_thumb_photo'] )
			{
				try
				{
					\IPS\File::get( 'core_Profile', $this->_data['pp_thumb_photo'] )->delete();
					$this->_data['pp_thumb_photo'] = NULL;
				}
				catch ( \Exception $e ) {}
			}
		}
	}
	
	/**
	 * Get reputation points
	 *
	 * @return	int
	 */
	public function get_pp_reputation_points()
	{
		return isset( $this->_data['pp_reputation_points'] ) ? (int) $this->_data['pp_reputation_points'] : 0;
	}
	
	/**
	 * Get warning points
	 *
	 * @return	int
	 */
	public function get_warn_level()
	{
		return isset( $this->_data['warn_level'] ) ? (int) $this->_data['warn_level'] : 0;
	}
	
	/**
	 * Get failed login details
	 *
	 * @return	array
	 */
	public function get_failed_logins()
	{
		return json_decode( $this->_data['failed_logins'], TRUE ) ?: array();
	}


	/**
	 * Fetch the ranks - abstracted to a static method for caching
	 *
	 * @return	array
	 */
	public static function getRanks()
	{
		static $ranks = NULL;

		if( $ranks !== NULL )
		{
			return $ranks;
		}

		if ( isset( \IPS\Data\Store::i()->ranks ) )
		{
			$ranks = \IPS\Data\Store::i()->ranks;
		}
		else
		{
			$ranks = iterator_to_array( \IPS\Db::i()->select( '*', 'core_member_ranks', NULL, 'posts DESC' ) );
			\IPS\Data\Store::i()->ranks = $ranks;
		}

		return $ranks;
	}

	/**
	 * Get member title
	 *
	 * @return	array
	 */
	public function get_rank()
	{
		$title = NULL;
		$image = NULL;

		/* Does this member have a custom title? */
		if ( isset( $this->member_title ) )
		{
			$title = $this->member_title;
		}
		
		foreach( static::getRanks() as $rank )
		{
			if ( $this->member_posts >= $rank['posts'] )
			{
				/* Pips or Image */
				if ( $rank['use_icon'] and $rank['icon'] )
				{
					$image = "<img src='" . \IPS\File::get( 'core_Theme', $rank['icon'] )->url . "' alt=''>";
				}
				else
				{
					$image = str_repeat( "<span class='ipsPip'></span>", intval( $rank['pips'] ) );
				}
				
				/* Get member title from rank */
				if ( !$title )
				{
					$title = \IPS\Member::loggedIn()->language()->addToStack( 'core_member_rank_' . $rank['id']);
				}

				break;
			}
		}
		
		return array( 'title' => $title, 'image' => $image );
	}
	
	/**
	 * Get member location
	 *
	 * @return	string|null
	 */
	public function get_location()
	{
		return $this->location();
	}
	
	/**
	 * Get members posts for today
	 *
	 * @return	array
	 */
	public function get_members_day_posts()
	{
		return explode( ',', $this->_data['members_day_posts'] );
	}
	
	/**
	 * Set members posts for today
	 *
	 * @param	array	$value	Array of daily post data. Index 0 is the amount of posts posted in this time period, and optional index 1 is a timestamp of when we started counting
	 * @return	void
	 */
	public function set_members_day_posts( $value )
	{
		/* Are we updating time? */
		if ( ! isset( $value[1] ) )
		{
			$value[1] = $this->members_day_posts[1];
		}
		
		$this->_data['members_day_posts'] = implode( ',', $value );
	}
	
	/**
	 * Get member's default stream
	 *
	 * @return	int|null
	 */
	public function get_defaultStream()
	{
		if ( $this->defaultStreamId === FALSE )
		{
			if ( $this->member_streams and $streams = json_decode( $this->member_streams, TRUE ) and count( $streams ) )
			{
				$this->defaultStreamId = ( isset( $streams['default'] ) ? $streams['default'] : NULL );
			}
			else
			{
				$this->defaultStreamId = NULL;
			}
		}
		
		return $this->defaultStreamId;
	}
	
	/**
	 * Set member's default stream
	 *
	 * @param	null|int	$value	Null or stream ID. 0 is for 'all activity'
	 * @return	void
	 */
	public function set_defaultStream( $value )
	{
		if ( $this->member_streams and $streams = json_decode( $this->member_streams, TRUE ) and count( $streams ) )
		{
			$streams['default'] = $value;
		}
		else
		{
			$streams = array( 'streams' => array(), 'default' => $value );
		}
		
		$this->member_streams = json_encode( $streams );
		$this->save();
		
		$this->defaultStreamId = $value;
	}
	
	/**
	 * Get member location
	 *
	 * @return	string|null
	 */
	public function location()
	{
		if( $this->sessionData === FALSE )
		{
			return NULL;
		}

		if( $this->sessionData === NULL )
		{
			try
			{
				$this->sessionData = \IPS\Db::i()->select( '*', 'core_sessions', array( 'member_id=?', $this->member_id ), 'running_time DESC' )->first();
			}
			catch ( \UnderflowException $e ) {}
		}
		
		return ( $this->sessionData ) ? \IPS\Session::i()->getLocation( $this->sessionData ) : NULL;
	}

	/**
	 * Get validating description
	 *
	 * @param 	null	$validatingRow
	 * @return 	string
	 */
	public function validatingDescription( $validatingRow=NULL )
	{
		try
		{
			$validatingRow = ( $validatingRow ) ?: \IPS\Db::i()->select( '*', 'core_validating', array( 'member_id=?', $this->member_id ) )->first();
		}
		catch( \UnderflowException $ex )
		{
			return '';
		}
		
		$validatingDescription = '';
		if ( $validatingRow['new_reg'] )
		{
			if ( $validatingRow['reg_cancelled'] )
			{
				$validatingDescription = \IPS\Member::loggedIn()->language()->addToStack('members_validating_cancelled');
			}
			elseif ( $validatingRow['user_verified'] )
			{
				$validatingDescription = \IPS\Member::loggedIn()->language()->addToStack('members_validating_admin');
			}
			else
			{
				$validatingDescription = \IPS\Member::loggedIn()->language()->addToStack('members_validating_user');
			}
	
			if ( $validatingRow['coppa_user'] )
			{
				$validatingDescription .= \IPS\Member::loggedIn()->language()->addToStack('members_validating_coppa');
			}
	
			if ( $validatingRow['spam_flag'] )
			{
				$validatingDescription .= \IPS\Member::loggedIn()->language()->addToStack('members_validating_spam');
			}
		}
		elseif ( $validatingRow['email_chg'] )
		{
			$validatingDescription .= \IPS\Member::loggedIn()->language()->addToStack('members_validating_email_chg');
		}
		
		return $validatingDescription;
	}
	
	/**
	 * Followers
	 *
	 * @param	int						$privacy		static::FOLLOW_PUBLIC + static::FOLLOW_ANONYMOUS
	 * @param	array					$frequencyTypes	array( 'immediate', 'daily', 'weekly' )
	 * @param	\IPS\DateTime|int|NULL	$date			Only users who started following before this date will be returned. NULL for no restriction
	 * @param	int|array				$limit			LIMIT clause
	 * @param	string					$order			Column to order by
	 * @param	int						$flags			Flags to pass to select (e.g. \IPS\Db::SELECT_SQL_CALC_FOUND_ROWS)
	 * @param	int
	 * @return	\IPS\Db\Select|NULL
	 * @throws	\BadMethodCallException
	 */
	public function followers( $privacy=3, $frequencyTypes=array( 'immediate', 'daily', 'weekly' ), $date=NULL, $limit=array( 0, 25 ), $order=NULL, $flags=\IPS\Db::SELECT_SQL_CALC_FOUND_ROWS )
	{
		if( $this->members_bitoptions['pp_setting_moderate_followers'] )
		{
			return NULL;
		}
		
		return static::_followers( 'member', $this->member_id, $privacy, $frequencyTypes, $date, $limit, $order, $flags );
	}
	
	/**
	 * Set failed login details
	 *
	 * @param	array	$data	Data
	 */
	public function set_failed_logins( $data )
	{
		$this->_data['failed_logins'] = json_encode( $data );
		
		$highest = 0;
		foreach ( $data as $ipAddress => $times )
		{
			if ( $highest < count( $times ) )
			{
				$highest = count( $times );
			}
		}
		$this->failed_login_count = $highest;
	}
	
	/**
	 * Get MFA details
	 *
	 * @return	array
	 */
	public function get_mfa_details()
	{
		return json_decode( $this->_data['mfa_details'], TRUE ) ?: array();
	}
	
	/**
	 * Set MFA details
	 *
	 * @param	array	$data	Data
	 */
	public function set_mfa_details( $data )
	{
		$this->_data['mfa_details'] = json_encode( $data );
	}
	
	/* !Photos */
	
	/**
	 * Columns needed to build photos
	 *
	 * @return	array
	 */
	public static function columnsForPhoto()
	{
		$return = array( 'member_id', 'name', 'members_seo_name', 'member_group_id', 'mgroup_others', 'pp_photo_type', 'pp_main_photo', 'pp_thumb_photo' );
		
		if ( \IPS\Settings::i()->allow_gravatars )
		{
			$return[] = 'pp_gravatar';
			$return[] = 'email';
			$return[] = 'members_bitoptions';
		}
		
		return $return;
	}
	
	/**
	 * Get photo from data
	 *
	 * @param	array	$memberData			Array of member data, must include values for at least the keys returned by columnsForPhoto()
	 * @param	bool	$thumb				Use thumbnail?
	 * @param	bool	$email				Is the photo going to be used in an email?
	 * @param	bool	$useDefaultPhoto	If there is no photo, should the default (rather than NULL) be returned? (If Gravatar is enabled, this will have no effect)
	 * @return	string
	 */
	public static function photoUrl( $memberData, $thumb=TRUE, $email=FALSE, $useDefaultPhoto=TRUE )
	{
		$gravatar = FALSE;
		$photoUrl = NULL;
		
		/* All this only applies to members... */
		if ( isset( $memberData['member_id'] ) and $memberData['member_id'] )
		{
			/* Is Gravatar disabled for them? */
			$gravatarDisabled = isset( $memberData['member_bitoptions'] ) AND is_object( $memberData['members_bitoptions']  ) ? $memberData['members_bitoptions']['bw_disable_gravatar'] : $memberData['members_bitoptions'] & static::$bitOptions['members_bitoptions']['members_bitoptions']['bw_disable_gravatar'];

			/* Either uploaded or synced from social media */
			if ( $memberData['pp_main_photo'] and ( mb_substr( $memberData['pp_photo_type'], 0, 5 ) === 'sync-' or $memberData['pp_photo_type'] === 'custom' or ( \IPS\Settings::i()->letter_photos == 'letters' AND $memberData['pp_photo_type'] == 'letter' AND ( $gravatarDisabled OR !\IPS\Settings::i()->allow_gravatars ) ) ) )
			{
				try
				{
					$photoUrl = \IPS\File::get( 'core_Profile', ( $thumb and $memberData['pp_thumb_photo'] ) ? $memberData['pp_thumb_photo'] : $memberData['pp_main_photo'] )->url;
				}
				catch ( \InvalidArgumentException $e ) { }
			}
			/* Gravatar */
			elseif( \IPS\Settings::i()->allow_gravatars and ( ( $memberData['pp_photo_type'] === 'letter' OR $memberData['pp_photo_type'] === 'gravatar' ) or ( !$memberData['pp_photo_type'] and !$gravatarDisabled ) ) )
			{
				$photoUrl = \IPS\Theme::i()->resource( 'default_photo.png', 'core', 'global' );

				if( empty( $memberData['pp_main_photo'] ) )
				{
					if( $photo = static::generateLetterPhoto( $memberData ) )
					{
						$photoUrl = $photo;
					}
				}
				else
				{
					$photoUrl = \IPS\File::get( 'core_Profile', ( $thumb and $memberData['pp_thumb_photo'] ) ? $memberData['pp_thumb_photo'] : $memberData['pp_main_photo'] )->url;
				}
				$gravatar = TRUE;
			}
			elseif( \IPS\Settings::i()->letter_photos == 'letters' AND empty( $memberData['pp_main_photo'] ) )
			{
				if( $photo = static::generateLetterPhoto( $memberData ) )
				{
					$photoUrl = $photo;

					if( !$gravatarDisabled AND \IPS\Settings::i()->allow_gravatars )
					{
						$gravatar = TRUE;
					}
				}
			}
			
			/* Other - This allows an app (such as Gallery) to set the pp_photo_type to a storage container to support custom images without duplicating them */
			elseif( $memberData['pp_photo_type'] and $memberData['pp_photo_type'] != 'none' and mb_strpos( $memberData['pp_photo_type'], '_' ) !== FALSE )
			{
				try
				{
					$photoUrl = \IPS\File::get( $memberData['pp_photo_type'], $memberData['pp_main_photo'] )->url;
				}
				catch ( \InvalidArgumentException $e )
				{				
					/* If there was an exception, clear these values out - most likely the image or storage container is no longer valid */
					$member = \IPS\Member::load( $memberData['member_id'] );
					$member->pp_photo_type	= NULL;
					$member->pp_main_photo	= NULL;
					$member->save();
				}
			}

			if( $gravatar )
			{
				/* Construct the URL - Gravatar will error for localhost URLs, so if IN_DEV, don't send this (this way also allows us to easily see what is loading from Gravatar).*/
				$photoUrl = \IPS\Http\Url::external( "https://secure.gravatar.com/avatar/" . md5( trim( mb_strtolower( $memberData['pp_gravatar'] ?: $memberData['email'] ) ) ) )->setQueryString( array(
					'd'	=>  \IPS\IN_DEV ? '' : ( $photoUrl instanceof \IPS\Http\Url ? (string) $photoUrl->setScheme( \IPS\Request::i()->isSecure()  ? 'https' : 'http' ) : '' )
				) );
			}

			/* If we're in the ACP, munge because this is an external resource */
			if ( \IPS\Dispatcher::hasInstance() AND \IPS\Dispatcher::i()->controllerLocation === 'admin' AND ( $photoUrl instanceof \IPS\Http\Url ) )
			{
				$photoUrl = $photoUrl->makeSafeForAcp( TRUE );
			}

			/* Return */
			if( $photoUrl !== NULL )
			{
				return (string) $photoUrl;
			}
		}

		/* Still here? Return default photo */
		if ( !$photoUrl and $useDefaultPhoto )
		{
			if( $email )
			{
				return rtrim( \IPS\Settings::i()->base_url, '/' ) . '/applications/core/interface/email/default_photo.png';
			}
			else
			{
				if( $photo = static::generateLetterPhoto( $memberData ) )
				{
					return (string) $photo;
				}

				return (string) \IPS\Theme::i()->resource( 'default_photo.png', 'core', 'global' );
			}
		}
		return NULL;
	}

	/**
	 * Generate Letter Photo
	 *
	 * @param	array			$memberData
	 * @return	boolean
	 */
	public static function generateLetterPhoto( $memberData)
	{
		/* This theme has letter-photos enabled and this user has no photo, so generate a new letter photo if possible. */
		if( \IPS\Settings::i()->letter_photos == 'letters' AND \IPS\Image::canWriteText() AND isset( $memberData['member_id'] ) AND $memberData['member_id'] )
		{
			$member = \IPS\Member::load( $memberData['member_id'] );

			if( $member->pp_photo_type = 'letter' AND !empty( $member->pp_main_photo ) )
			{
				return \IPS\File::get( 'core_Profile', $member->pp_main_photo )->url;
			}

			$letterPhoto = new \IPS\Member\LetterPhoto( $member );
			$photoVars = static::getLetterPhotoDimensions( $member );

			if( $newPhoto = $letterPhoto->create( $photoVars['width'], $photoVars['height'] ) )
			{
				$photoUrl = $newPhoto->container . '/' . $newPhoto->filename;

				$member->pp_photo_type	= 'letter';
				$member->pp_main_photo	= $photoUrl;
				$member->pp_thumb_photo = NULL;
				$member->save();

				return $newPhoto->url;
			}
		}

		return FALSE;
	}

	/**
	 * Get dimensions for letter photo
	 *
	 * @note	Abstracted so third parties can override if desired
	 * @param	\IPS\Member	$member		Member
	 * @return	array
	 */
	protected static function getLetterPhotoDimensions( $member )
	{
		$photoVars = explode( 'x', \IPS\THUMBNAIL_SIZE );

		return array( 'width' => $photoVars[0], 'height' => $photoVars[1] );
	}

	/* !Get Calculated Properties */
	
	/**
	 * Get administrators
	 *
	 * @return	array
	 */
	public static function administrators()
	{
		if ( !isset( \IPS\Data\Store::i()->administrators ) )
		{
			\IPS\Data\Store::i()->administrators = array(
				'm'	=> iterator_to_array( \IPS\Db::i()->select( '*', 'core_admin_permission_rows', array( 'row_id_type=?', 'member' ) )->setKeyField( 'row_id' ) ),
				'g'	=> iterator_to_array( \IPS\Db::i()->select( '*', 'core_admin_permission_rows', array( 'row_id_type=?', 'group' ) )->setKeyField( 'row_id' ) ),
			);
		}
		return \IPS\Data\Store::i()->administrators;
	}
	
	/**
	 * Is an admin?
	 *
	 * @return	bool
	 */
	public function isAdmin()
	{
		return $this->acpRestrictions() !== FALSE;
	}

	/**
	 * @brief	Cache the session data if we pull it for location, etc.
	 */
	protected $sessionData	= NULL;
	
	/**
	 * Is online?
	 *
	 * @return	bool
	 */
	public function isOnline()
	{
		if( !$this->member_id )
		{
			return FALSE;
		}

		if ( $this->sessionData === NULL )
		{
	        try
	        {
	            $this->sessionData	= \IPS\Db::i()->select( '*', 'core_sessions', array( 'member_id = ?', $this->member_id ) )->first();
	        }
	        catch ( \UnderflowException $e )
	        {
	        	$this->sessionData	= FALSE;
	        }
		}
		
		if( $this->sessionData === FALSE )
		{
			return FALSE;
		}

		$diff = \IPS\DateTime::ts( $this->last_activity )->diff( \IPS\DateTime::create() );
		if ( $diff->y or $diff->m or $diff->d or $diff->h or $diff->i > 15 )
		{
			return FALSE;
		}
		return TRUE;
	}
	
	/**
	 * Is banned?
	 * If is banned until a certain time, returns an \IPS\DateTime object
	 *
	 * @return	FALSE|\IPS\DateTime|TRUE
	 */
	public function isBanned()
	{
		if ( $this->temp_ban != 0 )
		{
			if ( $this->temp_ban != -1 and time() >= $this->temp_ban )
			{
				$this->temp_ban = 0;
				$this->save();
				return FALSE;
			}
			elseif ( $this->temp_ban > 0 )
			{
				return \IPS\DateTime::ts( $this->temp_ban );
			}
			
			return TRUE;
		}

		if( !$this->group['g_view_board'] )
		{
			return TRUE;
		}
		
		return FALSE;
	}
		
	/**
	 * Is the member in a certain group (including secondary groups)
	 *
	 * @param	int|\IPS\Member\Group|array	$group				The group, or array of groups
	 * @param	bool						$permissionArray	If TRUE, checks the permission array rather than the groups
	 * @return	bool
	 */
	public function inGroup( $group, $permissionArray=FALSE )
	{
		$group = array_filter( is_array( $group ) ? $group : array( $group ) );
		$check = array_filter( $permissionArray ? $this->permissionArray() : $this->groups );

		foreach ( $group as $_group )
		{
			$groupId = ( $_group instanceof \IPS\Member\Group ) ? $_group->g_id : $_group;

			if ( in_array( $groupId, $check ) )
			{
				return TRUE;
			}
		}
		
		return FALSE;
	}

	/**
	 * Store a reference to the language object
	 */
	protected $_lang	= NULL;
	
	/**
	 * Return the language object to use for this member - returns default if member has not selected a language
	 *
	 * @param	bool	$frontOnly	If TRUE, will only look at the the langauge for the front-end, not the AdminCP
	 * @return	\IPS\Lang
	 */
	public function language( $frontOnly=FALSE )
	{
		/* Did we already load the language object? */
		if( $this->_lang !== NULL )
		{
			return $this->_lang;
		}
		
		/* If in setup, create a "dummy" language */
		if ( \IPS\Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND \IPS\Dispatcher::i()->controllerLocation === 'setup' AND \IPS\Dispatcher::i()->setupLocation === 'install' )
		{
			$this->_lang = \IPS\Lang::setupLanguage();
			return $this->_lang;
		}
		else if ( \IPS\Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND \IPS\Dispatcher::i()->controllerLocation === 'setup' AND \IPS\Dispatcher::i()->setupLocation === 'upgrade' )
		{
			$this->_lang = \IPS\Lang::upgraderLanguage();
			return $this->_lang;
		}

		/* Work out if we are getting the ACP language or the normal language */
		$column	= 'language';
		if ( !$frontOnly and \IPS\Dispatcher::hasInstance() and \IPS\Dispatcher::i()->controllerLocation == 'admin' and $this->member_id and $this->member_id == static::loggedIn()->member_id )
		{
			$column	= 'acp_language';
		}
		
		/* If the member has a language set, try that */
		if( $this->calculatedLanguageId !== NULL or $this->$column )
		{
			try
			{
				$this->_lang	= \IPS\Lang::load( $this->calculatedLanguageId ?: $this->$column );

				if( $this->_lang->enabled )
				{
					return $this->_lang;
				}
			}
			catch ( \OutOfRangeException $e ) { }
		}
		
		/* Otherwise, if this is us, try looking at HTTP_ACCEPT_LANGUAGE */
		if ( \IPS\Dispatcher::hasInstance() and $this->member_id == static::loggedIn()->member_id )
		{	
			/* Work out what's in HTTP_ACCEPT_LANGUAGE */
			$preferredLanguage = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? \IPS\Lang::autoDetectLanguage( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) : NULL;
			
			/* If we worked one out, use that and save it on the account so it gets used for emails etc */
			if ( $preferredLanguage )
			{
				$this->calculatedLanguageId = $preferredLanguage;
				
				if ( $this->member_id )
				{
					$this->$column = $preferredLanguage;
					$this->save();
				}
			}
			/* Otherwise, just use the default */
			else
			{
				$this->calculatedLanguageId = \IPS\Lang::defaultLanguage();
			}
		}		
		else
		{
			/* Just return the default language */
			$this->calculatedLanguageId = \IPS\Lang::defaultLanguage();
		}
		
		/* Set it */
		$this->_lang = \IPS\Lang::load( $this->calculatedLanguageId );
		
		/* Add upgrader language bits if appropriate */
		if ( \IPS\Dispatcher::hasInstance() AND class_exists( 'IPS\Dispatcher', FALSE ) AND \IPS\Dispatcher::i()->controllerLocation === 'setup' AND \IPS\Dispatcher::i()->setupLocation === 'upgrade' )
		{
			$this->_lang->upgraderLanguage();
		}
		
		/* Return */
		return $this->_lang;
	}

	/**
	 * @brief	Cached URL
	 */
	protected $_url	= NULL;

	/**
	 * Get URL
	 *
	 * @return	\IPS\Http\Url
	 */
	public function url()
	{
		if( $this->_url === NULL )
		{
			$this->_url = \IPS\Http\Url::internal( "app=core&module=members&controller=profile&id={$this->member_id}", 'front', 'profile', $this->members_seo_name );
		}

		return $this->_url;
	}
	
	/**
	 * URL to ACP "Edit Member"
	 *
	 * @return	\IPS\Http\Url
	 */
	public function acpUrl()
	{
		return \IPS\Http\Url::internal( "app=core&module=members&controller=members&do=edit&id={$this->member_id}", 'admin' );
	}
	
	/**
	 * HTML link to profile with hovercard
	 *
	 * @param	string|NULL		$warningRef			The reference key for warnings
	 * @param	boolean 		$groupFormatting	Apply the group prefix/suffix to the name?
	 * @return	string
	 */
	public function link( $warningRef=NULL, $groupFormatting=FALSE )
	{
		if ( !\IPS\Settings::i()->warn_on )
		{
			$warningRef = NULL;
		}
		return \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->userLink( $this, $warningRef, $groupFormatting );
	}
	
	/**
	 * Profile Fields shown next to users content
	 */
	public $rawProfileFieldsData = NULL;

	/**
	 * Profile Fields
	 *
	 * @param	int			$location	\IPS\core\ProfileFields\Field::PROFILE for profile, \IPS\core\ProfileFields\Field::REG for registration screen or \IPS\core\ProfileFields\Field::STAFF for ModCP/ACP
	 * @return	array
	 */
	public function profileFields( $location = 0 )
	{
		if ( !$this->member_id )
		{
			return array();
		}

		if( $this->rawProfileFieldsData !== NULL )
		{
			return $this->rawProfileFieldsData;
		}

		$this->rawProfileFieldsData = array();
		$values = array();
		
		try
		{
			$values = \IPS\Db::i()->select( '*', 'core_pfields_content', array( 'member_id = ?', $this->member_id ) )->first();
		}
		catch ( \UnderflowException $e ) {}

		if( !empty( $values ) )
		{
			foreach ( \IPS\core\ProfileFields\Field::values( $values, $location ) as $group => $fields )
			{
				$this->rawProfileFieldsData[ 'core_pfieldgroups_' . $group ] =  $fields;
			}
		}

		return $this->rawProfileFieldsData;
	}
	
	
	/**
	 * Profile Fields shown next to users content
	 */
	public $profileFields;

	/**
	 * Profile Fields shown next to users content
	 *
	 * @return array
	 */
	public function contentProfileFields()
	{
		if ( $this->profileFields === NULL )
		{
			$this->profileFields = array();
			if ( $this->member_id AND \IPS\core\ProfileFields\Field::fieldsForContentView() )
			{
				$select = '*';

				/* Can we view private fields? */
				if( !( \IPS\Member::loggedIn()->isAdmin() OR \IPS\Member::loggedIn()->member_id === $this->member_id ) )
				{
					$select = 'member_id';
					$publicFields = \IPS\Db::i()->select( 'pf_id', 'core_pfields_data', array( 'pf_member_hide = ?', 0 ) );
					foreach( $publicFields as $field)
					{
						$select .= ", field_{$field}";
					}
				}
				try
				{
					$values = \IPS\Db::i()->select( $select, 'core_pfields_content', array( 'member_id = ?', $this->member_id ) )->first();
					if ( is_array( $values ) )
					{
						$this->setProfileFieldValuesInMemory( $values );
					}
				}
				catch ( \UnderflowException $e ) {}
			}
		}

		return $this->profileFields;
	}
	
	/**
	 * Store profile field values in memory
	 *
	 * @param	array	$values
	 * @return	void
	 */
	public function setProfileFieldValuesInMemory( array $values )
	{
		$this->profileFields = array();

		$values = array_filter( $values, function ( $val) { return ( $val !== '' AND $val !== NULL ); } );

		if( !empty( $values ) )
		{
			foreach ( \IPS\core\ProfileFields\Field::values( $values, \IPS\core\ProfileFields\Field::CONTENT ) as $group => $fields )
			{
				$this->profileFields[ 'core_pfieldgroups_' . $group ] = str_replace( '{member_id}', $this->member_id, $fields );
			}
		}
	}
		
	/**
	 * IP Addresses
	 *
	 * @code
	 	return array(
	 		'::1' => array(
		 		'count'		=> ...	// int (number of times this member has used this IP)
		 		'first'		=> ... 	// \IPS\DateTime (first use)
		 		'last'		=> ... 	// \IPS\DateTime (last use)
		 	),
		 	...
	 	);
	 * @endcode
	 * @return	array
	 */
	public function ipAddresses()
	{
		$return = array();
		
		foreach ( \IPS\Application::allExtensions( 'core', 'IpAddresses' ) as $class )
		{
			$results	= $class->findByMember( $this );

			if( $results === NULL )
			{
				continue;
			}

			foreach ( $results as $ip => $data )
			{
				if ( isset( $return[ $ip ] ) )
				{
					$return[ $ip ]['count'] += $data['count'];
					if ( $data['first'] < $return[ $ip ]['first'] )
					{
						$return[ $ip ]['first'] = $data['first'];
					}
					if ( $data['last'] > $return[ $ip ]['last'] )
					{
						$return[ $ip ]['last'] = $data['last'];
					}
				}
				else
				{
					if ( $ip )
					{
						$return[ $ip ] = $data;
					}
				}
			}
		}
		
		return $return;
	}
	
	/**
	 * Mark the entire site as read
	 *
	 * @return void
	 */
	public function markAllAsRead()
	{
		/* Delete all member markers */
		\IPS\Db::i()->delete( 'core_item_markers', array( 'item_member_id=?', $this->member_id ) );
		
		$this->marked_site_read = time();
		$this->save();
	}
	
	/**
	 * Get read/unread markers
	 *
	 * @param	string	$app	Application key
	 * @param	string	$key	Marker key
	 * @return	array
	 */
	public function markersItems( $app, $key )
	{
		if ( !isset( $this->markers[ $app ] ) or !array_key_exists( $key, $this->markers[ $app ] ) )
		{
			try
			{
				$marker = \IPS\Db::i()->select( '*', 'core_item_markers', array( 'item_key=? AND item_member_id=? AND item_app=?', $key, $this->member_id, $app ) )->first();
				$this->markers[ $app ][ $key ] = $marker;
			}
			catch ( \UnderflowException $e )
			{
				$this->markers[ $app ][ $key ] = NULL;
			}
		}
		return $this->markers[ $app ][ $key ] ? json_decode( $this->markers[ $app ][ $key ]['item_read_array'], TRUE ) : array();
	}
	
	/**
	 * Get read/unread markers for containers
	 *
	 * @param	string|NULL	$app	Application key or NULL for all applications
	 * @return	array
	 */
	public function markersResetTimes( $app )
	{
		if ( ( !$app and !$this->haveAllMarkers ) or ( $app and !isset( $this->markersResetTimes[ $app ] ) ) )
		{
			try
			{
				$where = array( array( 'item_member_id=?', $this->member_id ) );
				if ( $app )
				{
					$this->markersResetTimes[ $app ] = array();
					$where[] = array( 'item_app=?', $app );
				}
				else
				{
					$this->markersResetTimes = array();
				}
				
				
				foreach ( \IPS\Db::i()->select( '*', 'core_item_markers', $where ) as $row )
				{					
					if( !isset( $this->markersResetTimes[ $row['item_app'] ] ) or !is_array( $this->markersResetTimes[ $row['item_app'] ] ) )
					{
						$this->markersResetTimes[ $row['item_app'] ] = array();
					}

					if ( $row['item_app_key_1'] )
					{
						if ( $row['item_app_key_2'] )
						{
							if( !isset( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] ) OR !is_array( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] ) )
							{
								$this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ]	= array();
							}

							if ( $row['item_app_key_3'] )
							{
								if( !isset( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] ) OR !is_array( $this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] ) )
								{
									$this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ]	= array();
								}
								
								$this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ][ $row['item_app_key_3'] ] = $row['item_global_reset'];
							}
							else
							{
								$this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ][ $row['item_app_key_2'] ] = $row['item_global_reset'];
							}
						}
						else
						{
							$this->markersResetTimes[ $row['item_app'] ][ $row['item_app_key_1'] ] = $row['item_global_reset'];
						}
					}
					else
					{
						$this->markersResetTimes[ $row['item_app'] ] = $row['item_global_reset'];
					}
					
					$this->markers[ $row['item_app'] ][ $row['item_key'] ] = $row;
				}
				
				if ( !$app )
				{
					$this->haveAllMarkers = TRUE;
				}
			}
			catch ( \UnderflowException $e )
			{
				if ( $app )
				{
					$this->markersResetTimes[ $app ] = array();
				}
				else
				{
					$this->markersResetTimes = array();
				}
			}
		}
		
		if ( $app )
		{
			return $this->markersResetTimes[ $app ];
		}
		else
		{
			return $this->markersResetTimes;
		}
	}
	
	/**
	 * Get Warnings
	 *
	 * @param	int			$limit			The number to get
	 * @param	bool|NULL	$acknowledged	If true, will only get warnings that have been acknowledged, if false will only get warnings that have not been knowledged. If NULL, will get both.
	 * @param	string|NULL	$type			If specified, will only pull warnings that applied a specific action.
	 * @return	\IPS\Patterns\ActiveRecordIterator
	 */
	public function warnings( $limit, $acknowledged=NULL, $type=NULL )
	{
		if ( !$this->member_id )
		{
			return array();
		}
		
		if ( !\IPS\Settings::i()->warn_on )
		{
			return array();
		}
		
		$where = array( array( 'wl_member=?', $this->member_id ) );
		if ( $acknowledged !== NULL )
		{
			$where[] = array( 'wl_acknowledged=?', $acknowledged );
		}
		
		switch ( $type )
		{
			case 'mq':
				$where[] = array( 'wl_mq<>0' );
				break;
			case 'rpa':
				$where[] = array( 'wl_rpa<>0' );
				break;
			case 'suspend':
				$where[] = array( 'wl_suspend<>0' );
				break;
		}
				
		return new \IPS\Patterns\ActiveRecordIterator( \IPS\Db::i()->select( '*', 'core_members_warn_logs', $where, 'wl_date DESC', $limit, NULL, NULL, \IPS\Db::SELECT_DISTINCT ), 'IPS\core\Warnings\Warning' );
	}

	/**
	 * @brief	Cached reputation data
	 */
	protected $_reputationData	= NULL;
	
	/**
	 * Calculate and cache the member's reputation level data
	 *
	 * @return	void
	 */
	protected function getReputationData()
	{
		if( $this->_reputationData === NULL )
		{
			$this->_reputationData	= array();
			
			if ( isset( \IPS\Data\Store::i()->reputationLevels ) )
			{
				$reputationLevels = \IPS\Data\Store::i()->reputationLevels;
			}
			else
			{
				$reputationLevels = iterator_to_array( \IPS\Db::i()->select( '*', 'core_reputation_levels', NULL, 'level_points DESC' ) );
				\IPS\Data\Store::i()->reputationLevels = $reputationLevels;
			}
			
			foreach ( $reputationLevels as $level )
			{
				if ( $this->pp_reputation_points >= $level['level_points'] )
				{
					$this->_reputationData = $level;
					break;
				}
			}
		}

		return $this->_reputationData;
	}
	
	/**
	 * @brief	Cached reputation last day won
	 */
	protected $_reputationLastDayWon = NULL;
		
	/**
	 * Return the 'date' of the last day won, along with the 'rep_total'.
	 *
	 * @return array( 'date' => \IPS\DateTime, 'rep_total' => int )|FALSE
	 */
	public function getReputationLastDayWon()
	{
		if ( $this->_reputationLastDayWon === NULL )
		{
			try
			{
				$dayWon = \IPS\Db::i()->select( 'leader_date, leader_rep_total', 'core_reputation_leaderboard_history', array( 'leader_position=1 AND leader_member_id=?', $this->member_id ), 'leader_date DESC', array( 0, 1 ) )->first();
				/* The 'day won' must be in the leaderboard timezone otherwise it will be off for people in significantly different timezones */
				$this->_reputationLastDayWon = array( 'date' => \IPS\DateTime::ts( $dayWon['leader_date'], true )->setTimezone( new \DateTimeZone( \IPS\Settings::i()->reputation_timezone ) ), 'rep_total' => $dayWon['leader_rep_total'] );
				
			}
			catch( \UnderflowException $ex )
			{
				$this->_reputationLastDayWon = FALSE;
			}
		}
		
		return $this->_reputationLastDayWon;
	}
	
	/**
	 * @brief	Cached reputation days won count
	 */
	protected $_reputationDaysWonCount = NULL;
	
	/**
	 * Return the total number of days won
	 *
	 * @return int
	 */
	public function getReputationDaysWonCount()
	{
		if ( $this->_reputationDaysWonCount === NULL )
		{
			$this->_reputationDaysWonCount = \IPS\Db::i()->select( 'COUNT(*)', 'core_reputation_leaderboard_history', array( 'leader_position=1 AND leader_member_id=?', $this->member_id ) )->first();
		}
		
		return $this->_reputationDaysWonCount;
	}

	/**
	 * Reputation level description
	 *
	 * @return	string|NULL
	 */
	public function reputation()
	{
		$level	= $this->getReputationData();
		
		if( isset( $level['level_id'] ) )
		{
			return \IPS\Member::loggedIn()->language()->addToStack( 'core_reputation_level_' . $level['level_id'] );
		}
		else
		{
			return NULL;
		}
	}

	/**
	 * Reputation image
	 *
	 * @return	string|NULL
	 */
	public function reputationImage()
	{
		$level	= $this->getReputationData();
		
		if( isset( $level['level_id'] ) )
		{
			return $level['level_image'];
		}
		else
		{
			return NULL;
		}
	}
	
	/**
	 * Encrypt a plaintext password
	 *
	 * @param	string	$password	Password to encrypt
	 * @return	string	Encrypted password
	 * @todo	[Future] When we increase minimum PHP version, adjust blowfish to $2y$
	 */
	public function encryptedPassword( $password )
	{
		/* New password style introduced in 4.0 using Blowfish */
		if ( mb_strlen( $this->members_pass_salt ) === 22 )
		{
			return crypt( $password, '$2a$13$' . $this->members_pass_salt );
		}
		/* Old encryption style using md5 */
		else
		{
			return md5( md5( $this->members_pass_salt ) . md5( \IPS\Request::legacyEscape( $password ) ) );
		}
	}
	
	/**
	 * Notifications Configuration
	 *
	 * @return	array
	 */
	public function notificationsConfiguration()
	{
		$return = array();

		foreach (
			\IPS\Db::i()->select(
				'd.*, p.preference',
				array( 'core_notification_defaults', 'd' )
			)->join(
				array( 'core_notification_preferences', 'p' ),
				array( 'd.notification_key=p.notification_key AND p.member_id=?', $this->member_id )
			)
			as $row
		) {
			if ( $row['preference'] === NULL or !$row['editable'] )
			{
				$return[ $row['notification_key'] ] = explode( ',', $row['default'] );
			}
			else
			{
				$return[ $row['notification_key'] ] = array_diff( explode( ',', $row['preference'] ), explode( ',', $row['disabled'] ) );
			}
		}

		return $return;
	}
	
	/**
	 * @brief	Following?
	 */
	protected $_following	= array();

	/**
	 * Following
	 *
	 * @param	string	$app	Application key
	 * @param	string	$area	Area
	 * @param	int		$id		Item ID
	 * @return	bool
	 */
	public function following( $app, $area, $id )
	{
		$_key	= md5( $app . $area . $id );
		if( isset( $this->_following[ $_key ] ) )
		{
			return $this->_following[ $_key ];
		}

		try
		{
			\IPS\Db::i()->select( 'follow_id', 'core_follow', array( 'follow_app=? AND follow_area=? AND follow_rel_id=? AND follow_member_id=?', $app, $area, $id, $this->member_id ) )->first();
			$this->_following[ $_key ]	= TRUE;
		}
		catch ( \UnderflowException $e )
		{
			$this->_following[ $_key ]	= FALSE;
		}

		return $this->_following[ $_key ];
	}
	
	/**
	 * Admin CP Restrictions
	 *
	 * @return	array
	 */
	protected function acpRestrictions()
	{
		if ( !$this->member_id )
		{
			return FALSE;
		}
		
		if ( $this->restrictions === NULL )
		{
			if ( !isset( \IPS\Data\Store::i()->administrators ) )
			{
				\IPS\Data\Store::i()->administrators = array(
					'm'	=> iterator_to_array( \IPS\Db::i()->select( '*', 'core_admin_permission_rows', array( 'row_id_type=?', 'member' ) )->setKeyField( 'row_id' ) ),
					'g'	=> iterator_to_array( \IPS\Db::i()->select( '*', 'core_admin_permission_rows', array( 'row_id_type=?', 'group' ) )->setKeyField( 'row_id' ) ),
				);
			}
			
			$rows = array();
			if ( isset( \IPS\Data\Store::i()->administrators['m'][ $this->member_id ] ) )
			{
				$rows[] = \IPS\Data\Store::i()->administrators['m'][ $this->member_id ];
			}
			foreach ( $this->groups as $id )
			{
				if ( isset( \IPS\Data\Store::i()->administrators['g'][ $id ] ) )
				{
					$rows[] = \IPS\Data\Store::i()->administrators['g'][ $id ];
				}
			}
									
			$this->restrictions = FALSE;
			if ( count( $rows ) > 0 )
			{
				$this->restrictions = array();
				foreach ( $rows as $row )
				{
					if ( $row['row_perm_cache'] === '*' )
					{
						$this->restrictions = '*';
						break;
					}
					
					$perms = json_decode( $row['row_perm_cache'], TRUE );
					if ( $row['row_id_type'] === 'member' )
					{
						$this->restrictions = $perms;
						break;
					}
					else if( is_array( $perms ) )
					{
						if ( empty( $this->restrictions ) )
						{
							$this->restrictions = $perms;
						}
						else
						{
							if( isset( $perms['applications'] ) )
							{
								foreach ( $perms['applications'] as $app => $modules )
								{
									if ( !isset( $this->restrictions['applications'][ $app ] ) )
									{
										$this->restrictions['applications'][ $app ] = array();
									}

									foreach ( $modules as $module )
									{
										if ( !isset( $this->restrictions['applications'][ $app ][ $module ] ) )
										{
											$this->restrictions['applications'][ $app ][ $module ] = $module;
										}
									}
								}
							}
							if( isset( $perms['items'] ) )
							{
								foreach ( $perms['items'] as $app => $modules )
								{
									if ( !isset( $this->restrictions['items'][ $app ] ) )
									{
										$this->restrictions['items'][ $app ] = array();
									}

									foreach ( $modules as $module => $items )
									{
										if ( !isset( $this->restrictions['items'][ $app ][ $module ] ) )
										{
											$this->restrictions['items'][ $app ][ $module ] = array();
										}

										foreach ( $items as $item )
										{
											if ( !in_array( $item, $this->restrictions['items'][ $app ][ $module ] ) )
											{
												$this->restrictions['items'][ $app ][ $module ][] = $item;
											}
										}
									}
								}
							}
						}
					}
				}
			}
		}
		
		return $this->restrictions;
	}
	
	/**
	 * Moderator Permissions
	 *
	 * @return	array
	 */
	public function modPermissions()
	{
		/* Only members can be moderators of course */
		if ( !$this->member_id )
		{
			return FALSE;
		}
		
		/* Work out the permissions... */	
		if ( $this->modPermissions === NULL )
		{
			/* Start with FALSE (no moderator permissions) */
			$this->modPermissions = FALSE;
			
			/* If we don't have a datastore of moderator configuration, load that now */
			if ( !isset( \IPS\Data\Store::i()->moderators ) )
			{
				\IPS\Data\Store::i()->moderators = array(
					'm'	=> iterator_to_array( \IPS\Db::i()->select( '*', 'core_moderators', array( 'type=?', 'm' ) )->setKeyField( 'id' ) ),
					'g'	=> iterator_to_array( \IPS\Db::i()->select( '*', 'core_moderators', array( 'type=?', 'g' ) )->setKeyField( 'id' ) ),
				);
			}
			
			/* Member-level moderator permissions override all group-level permissions, so if this member is a moderator at a member-level, just use that */
			if ( isset( \IPS\Data\Store::i()->moderators['m'][ $this->member_id ] ) )
			{
				$perms = \IPS\Data\Store::i()->moderators['m'][ $this->member_id ]['perms'];
				$this->modPermissions = $perms == '*' ? '*' : json_decode( $perms, TRUE );
			}
			
			/* Otherwise, examine the groups and combine the permissions each group awards */
			else
			{
				/* Get all the groups the member is in which have moderator permissions... */
				$rows = array();
				foreach ( $this->groups as $id )
				{
					if ( isset( \IPS\Data\Store::i()->moderators['g'][ $id ] ) )
					{
						$rows[] = \IPS\Data\Store::i()->moderators['g'][ $id ];
					}
				}
				
				/* And if we have any... */			
				if ( count( $rows ) > 0 )
				{
					/* Start with an empty array (indicates they are a moderator, but haven't get given them any permissions) */
					$this->modPermissions = array();
					
					/* Loop the groups and combine the permissions... */
					foreach ( $rows as $row )
					{
						/* If any group has all permissions, this user has all moderator permissions and we don't need to go further */
						if ( $row['perms'] === '*' )
						{
							$this->modPermissions = '*';
							break;
						}
						
						/* Otherwise, examine what permissions this group has... */
						$perms = json_decode( $row['perms'], TRUE );
						if( !empty( $perms ) )
						{
							foreach ( $perms as $k => $v )
							{
								/* If we haven't seen this permission key at all, give them the value */
								if ( !isset( $this->modPermissions[ $k ] ) )
								{
									$this->modPermissions[ $k ] = $v;
								}
								/* If it's an array, combine the values */
								elseif ( is_array( $this->modPermissions[ $k ] ) AND is_array( $v ) )
								{
									$this->modPermissions[ $k ] = array_merge( $this->modPermissions[ $k ], $v );
								}
								
								/* If it's a number, they get the higher one, or -1 is best */
								elseif ( $v == -1 or ( $this->modPermissions[ $k ] != -1 and $v > $this->modPermissions[ $k ] ) )
								{
									$this->modPermissions[ $k ] = $v;
								}
							}
						}
					}
				}
			}
		}

		/* Return */
		return $this->modPermissions;
	}
	
	/**
	 * @brief	Report count
	 */
	protected $reportCount = NULL;
	
	/**
	 * Get number of open reports that this member can see
	 *
	 * @return	int
	 */
	public function reportCount()
	{
		if ( $this->reportCount === NULL )
		{
			if ( \IPS\Member::loggedIn()->canAccessModule( \IPS\Application\Module::get( 'core', 'modcp' ) ) )
			{
				if( !\IPS\Member::loggedIn()->members_bitoptions['no_report_count'] )
				{
					$where = '( perm_id IN (?) OR perm_id IS NULL ) AND status IN( 1,2 )';

					/* fetch only reports for content of enabled applications */
					$where .= " AND " . \IPS\Db::i()->in( 'class', array_merge( array( 'IPS\core\Messenger\Conversation', 'IPS\core\Messenger\Message' ), array_values( \IPS\Content::routedClasses( FALSE, TRUE ) ) ) );
					$this->reportCount = \IPS\Db::i()->select(
						'COUNT(*)',
						'core_rc_index',
						array( $where, \IPS\Db::i()->select( 'perm_id', 'core_permission_index', \IPS\Db::i()->findInSet( 'perm_view', array_merge( array( \IPS\Member::loggedIn()->member_group_id ), array_filter( explode( ',', \IPS\Member::loggedIn()->mgroup_others ) ) ) ) . " OR perm_view='*'" ) )
					)->first();
				}
			}
			else
			{
				$this->reportCount = 0;
			}
		}
		
		return (int) $this->reportCount;
	}
	
	/**
	 * @brief	Ignore Preferences
	 * @see		\IPS\Member::isIgnoring()
	 */
	protected $ignorePreferences = NULL;
	
	/**
	 * Is this member ignoring another member?
	 *
	 * @param	\IPS\Member|array	$member	The member
	 * @param	string				$type	The type (topics, messages, signatures)
	 * @return	bool
	 */
	public function isIgnoring( $member, $type )
	{
		if( is_array( $member ) )
		{
			$member = \IPS\Member::load( $member['member_id'] );
		}

		$group = $member->group;
		$id    = $member->member_id;
		$cannotBeIgnored = !$member->canBeIgnored();

		if ( $cannotBeIgnored or !$this->member_id )
		{
			return FALSE;
		}
		
		if ( $this->ignorePreferences === NULL )
		{
			if ( $this->members_bitoptions['has_no_ignored_users'] )
			{
				$this->ignorePreferences = array();
			}
			else
			{
				$this->ignorePreferences = iterator_to_array( \IPS\Db::i()->select( '*', 'core_ignored_users', array( 'ignore_owner_id=?', $this->member_id ) )->setKeyField( 'ignore_ignore_id' ) );
				
				if ( empty( $this->ignorePreferences ) )
				{
					$this->members_bitoptions['has_no_ignored_users'] = TRUE;
					$this->save();
				}
			}
		}
				
		if ( isset( $this->ignorePreferences[ $id ] ) )
		{
			return (bool) $this->ignorePreferences[ $id ][ 'ignore_' . $type ];
		}
		
		return FALSE;
	}
	
	/**
	 * Build the "Create" menu
	 *
	 * @return	array
	 */
	public function createMenu()
	{
		$menu = NULL;
		$rebuild = FALSE;

		/* Make sure that this is a valid member */
		if( !$this->member_id )
		{
			return array();
		}
		
		if ( ! \IPS\Settings::i()->member_menu_create_key )
		{
			/* Generate a new key */
			static::clearCreateMenu();
		}
		if ( \IPS\IN_DEV and !\IPS\DEV_USE_MENU_CACHE )
		{
			$rebuild = TRUE;
		}
		else if ( $this->create_menu !== NULL )
		{
			$menu = json_decode( $this->create_menu, TRUE );
			
			if ( ! isset( $menu['menu_key'] ) or $menu['menu_key'] != \IPS\Settings::i()->member_menu_create_key )
			{
				$rebuild = TRUE;
			}
		}
		else
		{
			$rebuild = TRUE;
		}
		
		if ( $rebuild === TRUE )
		{
			$createMenu = array();
			foreach ( \IPS\Application::allExtensions( 'core', 'CreateMenu', $this ) as $ext )
			{
				$createMenu = array_merge( $createMenu, array_map( function( $val )
				{
					$val['link'] = (string) $val['link'];
					return $val;
				}, $ext->getItems() ) );
			}
			
			$this->create_menu = json_encode( array( 'menu_key' => \IPS\Settings::i()->member_menu_create_key, 'menu' => $createMenu ) );
			$this->save();
			
			return $createMenu;
		}
		else
		{
			return $menu['menu'];
		}
	}
	
	/**
	 * Moderate New Content
	 *
	 * @return	bool
	 */
	public function moderateNewContent()
	{
		$modQueued = FALSE;
		if ( $this->group['g_mod_preview'] )
		{
			if ( $this->group['g_mod_post_unit'] )
			{
				/* Days since joining */
				if ( $this->group['gbw_mod_post_unit_type'] )
				{
					$modQueued = $this->joined->add( new \DateInterval( "P{$this->group['g_mod_post_unit']}D" ) )->getTimestamp() > time();
				}
				/* Content items */
				else
				{
					$modQueued = $this->member_posts < $this->group['g_mod_post_unit'];
				}
			}
			else
			{
				$modQueued = TRUE;
			}
		}
		
		/* If we're not group moderated what about individual member */
		if ( !$modQueued )
		{
			if( $this->mod_posts == -1 or ( $this->mod_posts > 0 and $this->mod_posts > time() ) )
			{
				$modQueued = TRUE;
			}
		}

		return $modQueued;
	}
	
	/**
	 * Cover Photo
	 *
	 * @return	\IPS\Helpers\CoverPhoto
	 */
	public function coverPhoto()
	{
		$photo = new \IPS\Helpers\CoverPhoto;
		if ( $this->pp_cover_photo )
		{
			$photo->file = \IPS\File::get( 'core_Profile', $this->pp_cover_photo );
			$photo->offset = $this->pp_cover_offset;
		}
		$photo->editable	= ( \IPS\Member::loggedIn()->modPermission('can_modify_profiles') or ( \IPS\Member::loggedIn()->member_id == $this->member_id and $this->group['g_edit_profile'] and $this->group['gbw_allow_upload_bgimage'] ) );
		$photo->maxSize		= $this->group['g_max_bgimg_upload'];
		$photo->object		= $this;
		
		return $photo;
	}
	
	/**
	 * Get HTML for search result display
	 *
	 * @return	callable
	 */
	public function searchResultHtml()
	{
		return \IPS\Theme::i()->getTemplate('search')->member( $this );
	}
	
	/**
	 * Should a reply made by this member be highlighted?
	 *
	 * @return	boolean
	 */
	public function hasHighlightedReplies()
	{
		return (boolean) $this->group['gbw_post_highlight'];
	}
	
	/**
	 * Get output for API
	 *
	 * @return	array
	 * @apiresponse	int											id						ID number
	 * @apiresponse	string										name					Username
	 * @apiresponse	string										title					Member title
	 * @apiresponse	string										timezone				Member timezone
	 * @apiresponse	string										formattedName			Username with group formatting
	 * @apiresponse	\IPS\Member\Group							primaryGroup			Primary group
	 * @apiresponse	[\IPS\Member\Group]							secondaryGroups			Secondary groups
	 * @apiresponse	string										email					Email address
	 * @apiresponse	datetime									joined					Registration date
	 * @apiresponse	string										registrationIpAddress	IP address when registered
	 * @apiresponse	string										photoUrl				URL to photo (which will be the site's default if they haven't set one)
	 * @apiresponse	string										profileUrl				URL to profile
	 * @apiresponse	bool										validating				Whether or not the validating flag is set on the member account
	 * @apiresponse	int											posts					Number of content item submissions member has made
	 * @apiresponse	datetime									lastActivity			Last activity date on the site
	 * @apiresponse	datetime									lastVisit				Last distinct visit date on the site
	 * @apiresponse	datetime									lastPost				Latest content submission date
	 * @apiresponse	int											profileViews			Number of times member's profile has been viewed
	 * @apiresponse	string										birthday				Member birthday in MM/DD/YYYY format (or MM/DD format if no year has been supplied)
	 * @apiresponse	[\IPS\core\ProfileFields\Api\FieldGroup]	customFields			Custom profile fields
	 */
	public function apiOutput()
	{
		$group = \IPS\Member\Group::load( $this->_data['member_group_id'] );
		
		$secondaryGroups = array();
		foreach ( array_filter( array_map( "intval", explode( ',', $this->_data['mgroup_others'] ) ) ) as $secondaryGroupId )
		{
			try
			{
				$secondaryGroups[] = \IPS\Member\Group::load( $secondaryGroupId )->apiOutput();
			}
			catch ( \OutOfRangeException $e ) { }
		}

		/* Figure out custom fields if any */
		$fields = array();
		
		try
		{
			$fieldData		= \IPS\core\ProfileFields\Field::fieldData();
			$fieldValues	= \IPS\Db::i()->select( '*', 'core_pfields_content', array( 'member_id=?', $this->member_id ) )->first();
	
			foreach( $fieldData as $profileFieldGroup => $profileFields )
			{
				$groupValues = array();
	
				foreach( $profileFields as $field )
				{
					$groupValues[ $field['pf_id'] ] = new \IPS\core\ProfileFields\Api\Field( $this->language()->get( 'core_pfield_' . $field['pf_id'] ), $fieldValues[ 'field_' . $field['pf_id'] ] );
				}
				
				$fields[ $profileFieldGroup ] = ( new \IPS\core\ProfileFields\Api\FieldGroup( $this->language()->get( 'core_pfieldgroups_' . $profileFieldGroup ), $groupValues ) )->apiOutput();
			}
		}
		catch( \UnderflowException $e ) { } # Guests will not have any profile field information.
		
		return array(
			'id'					=> $this->member_id,
			'name'					=> $this->name,
			'title'					=> $this->member_title,
			'timeZone'				=> $this->timezone,
			'formattedName'			=> $group->formatName( $this->name ),
			'primaryGroup'			=> $group->apiOutput(),
			'secondaryGroups'		=> $secondaryGroups,
			'email'					=> $this->email,
			'joined'				=> $this->joined->rfc3339(),
			'registrationIpAddress'	=> $this->ip_address,
			'warningPoints'			=> $this->warn_level,
			'reputationPoints'		=> $this->pp_reputation_points,
			'photoUrl'				=> (string) $this->get_photo( FALSE ),
			'profileUrl'			=> (string) $this->url(),
			'validating'			=> (bool) $this->members_bitoptions['validating'],
			'posts'					=> $this->member_posts,
			'lastActivity'			=> \IPS\DateTime::ts( $this->last_activity )->rfc3339(),
			'lastVisit'				=> \IPS\DateTime::ts( $this->last_visit )->rfc3339(),
			'lastPost'				=> \IPS\DateTime::ts( $this->member_last_post )->rfc3339(),
			'profileViews'			=> $this->members_profile_views,
			'birthday'				=> $this->bday_month ? ( $this->bday_month . '/' . $this->bday_day . ( $this->bday_year ? '/' . $this->bday_year : '' ) ) : NULL,
			'customFields'			=> $fields
		);
	}
	
	/**
	 * Answers to security questions
	 *
	 * @return	\IPS\Db\Select
	 */
	public function securityAnswers()
	{
		return \IPS\Db::i()->select( array( 'answer_question_id', 'answer_answer' ), 'core_security_answers', array( 'answer_member_id=?', $this->member_id ) )->setKeyField('answer_question_id')->setValueField('answer_answer');
	}
	
	/**
	 * Last used device
	 *
	 * @return	\IPS\Member\Device|NULL
	 */
	public function lastUsedDevice()
	{
		try
		{
			return \IPS\Member\Device::constructFromData( \IPS\Db::i()->select( '*', 'core_members_known_devices', array( 'member_id=?', $this->member_id ), 'last_seen DESC', 1 )->first() );
		}
		catch ( \Exception $e )
		{
			return NULL;
		}
	}
		
	/**
	 * Last used IP address
	 *
	 * @return	\IPS\Member\Device|NULL
	 */
	public function lastUsedIp()
	{
		try
		{
			return \IPS\Db::i()->select( 'ip_address', 'core_members_known_ip_addresses', array( 'member_id=?', $this->member_id ), 'last_seen DESC', 1 )->first();
		}
		catch ( \Exception $e )
		{
			return NULL;
		}
	}
		
	/**
	 * Device count
	 *
	 * @return	int
	 */
	public function deviceCount()
	{
		return \IPS\Db::i()->select( 'COUNT(*)', 'core_members_known_devices', array( 'member_id=?', $this->member_id ) )->first();
	}
		
	/* !Permissions */
	
	/**
	 * Has access to a restricted ACP area?
	 *
	 * @param	\IPS\Application|string				$app	Application
	 * @param	\IPS\Application\Module|string|null	$module	Module
	 * @param	string|null							$key	Restriction Key
	 */
	public function hasAcpRestriction( $app, $module=NULL, $key=NULL )
	{
		/* Load our ACP restrictions */
		$restrictions = $this->acpRestrictions();
		if ( $restrictions === FALSE )
		{
			return FALSE;
		}

		/* If we have all permissions, return true */
		if ( $restrictions === '*' )
		{
			return TRUE;
		}

		/* If we don't have any permissions, return false */
		if( !count( $restrictions ) )
		{
			return FALSE;
		}
		
		/* Otherwise, check 'em! */
		$appKey = is_string( $app ) ? $app : $app->directory;
		if ( array_key_exists( $appKey, $restrictions['applications'] ) )
		{
			if ( $module === NULL )
			{
				return TRUE;
			}
			else
			{
				$moduleKey = ( $module === NULL or is_string( $module ) ) ? $module : $module->key;
				if ( in_array( $moduleKey, $restrictions['applications'][ $appKey ] ) )
				{
					if ( $key === NULL )
					{
						return TRUE;
					}
					elseif ( isset( $restrictions['items'][ $appKey ][ $moduleKey ] ) and in_array( $key, $restrictions['items'][ $appKey ][ $moduleKey ] ) )
					{
						return TRUE;
					}
				}
			}
		}
		return FALSE;
	}
	
	/**
	 * Get moderator permission
	 *
	 * @param	string|NULL	$key	Permission Key to check, or NULL to just test if they have any moderator permissions.
	 * @return	mixed
	 */
	public function modPermission( $key=NULL )
	{
		/* Load our permissions */
		$permissions = $this->modPermissions();
				
		if ( $permissions == FALSE )
		{
			return FALSE;
		}
		
		/* If we have all permissions, return true */
		if ( $permissions === '*' or $key === NULL )
		{
			return TRUE;
		}
				
		/* Otherwise return it */
		return isset( $permissions[ $key ] ) ? $permissions[ $key ] : NULL;
	}
	
	/**
	 * Can warn
	 *
	 * @param	\IPS\Member	$member	The member to warn
	 * @return	bool
	 */
	public function canWarn( \IPS\Member $member )
	{
		if( !$this->modPermission('mod_can_warn') OR !$this->modPermission('mod_see_warn') )
		{
			return FALSE;
		}
		
		if( $member->inGroup( explode( ',', \IPS\Settings::i()->warn_protected ) ) or $member == \IPS\Member::loggedIn() )
		{
			return FALSE;
		}
		
		if ( $this->modPermission('warn_mod_day') !== TRUE and $this->modPermission('warn_mod_day') != -1 )
		{
			$oneDayAgo = \IPS\DateTime::create()->sub( new \DateInterval( 'P1D' ) );
			$warningsGivenInTheLastDay = \IPS\Db::i()->select( 'COUNT(*)', 'core_members_warn_logs', array( 'wl_moderator=? AND wl_date>?', $this->member_id, $oneDayAgo->getTimestamp() ) )->first();
			if( $warningsGivenInTheLastDay >= $this->modPermission('warn_mod_day') )
			{
				return FALSE;
			}
		}
		
		return TRUE;
	}
	
	/* !Recounting */
	
	/**
	 * Recalculate notification count
	 *
	 * @return	void
	 */
	public function recountNotifications()
	{
		$this->notification_cnt = \IPS\Db::i()->select( 'COUNT(*)', 'core_notifications', array( 'member=? AND read_time IS NULL', $this->member_id ), NULL, NULL, NULL, NULL, \IPS\Db::SELECT_FROM_WRITE_SERVER )->first();
		$this->save();
	}

	/**
	 * Recounts content for this member
	 *
	 * @return void
	 */
	public function recountContent()
	{
		$this->member_posts = 0;
        foreach ( \IPS\Content::routedClasses( $this, TRUE, FALSE ) as $class )
		{			
			$this->member_posts += $class::memberPostCount( $this );
		}
		
		$this->save();
	}
	
	/**
	 * Recounts reputation for this member
	 *
	 * @return void
	 */
	public function recountReputation()
	{
		$this->pp_reputation_points = \IPS\Db::i()->select( 'SUM(rep_rating)', 'core_reputation_index', array( 'member_received=?', $this->member_id ) );
		$this->save();
	}

	/**
	 * Removes reputation for this member
	 *
	 * @param	string	$type	given|received The type of reputation to remove
	 * @return void
	 */
	public function removeReputation( $type )
	{
		$where = ( $type == 'given' ) ? array( 'member_id=?', $this->member_id ) : array( 'member_received=?', $this->member_id );

		\IPS\Db::i()->delete( 'core_reputation_index', $where );

		if( $type == 'received' )
		{
			$this->recountReputation();
		}
		else
		{
			\IPS\Task::queue( 'core', 'RecountMemberReputation', array(), 4 );
		}
	}
	
	/* !Do Stuff */
	
	/**
	 * Generate a salt
	 *
	 * @return	string
	 */
	public function generateSalt()
	{
		$salt = '';
		for ( $i=0; $i<22; $i++ )
		{
			do
			{
				$chr = rand( 48, 122 );
			}
			while ( in_array( $chr, range( 58,  64 ) ) or in_array( $chr, range( 91,  96 ) ) );
			
			$salt .= chr( $chr );
		}
		return $salt;
	}
	
	/**
	 * Can use module
	 *
	 * @param	\IPS\Application\Module	$module	The module to test
	 * @return	bool
	 * @throws	\InvalidArgumentException
	 */
	public function canAccessModule( $module )
	{
		if ( !( $module instanceof \IPS\Application\Module ) )
		{
			throw new \InvalidArgumentException;
		}
		
		return \IPS\Application::load( $module->application )->canAccess( $this ) and ( $module->protected or $module->can( 'view', $this ) );
	}
		
	/**
	 * IPS Spam Monitoring Service
	 *
	 * @param	string	$type	Request type
	 * @return	int|NULL		Action code based on spam service response, or NULL for no action
	 */
	public function spamService( $type='register' )
	{
		try
		{
			$response = \IPS\Http\Url::ips( 'spam/' . $type )->request()->login( \IPS\Settings::i()->ipb_reg_number, '' )->post( array(
				'email'	=> $this->email,
				'ip'	=> $this->ip_address,
			) );
			if ( $response->httpResponseCode !== 200 )
			{
				$spamCode = intval( (string) $response );
			}
			else
			{
				$spamCode = 0;
			}
		}
		catch ( \IPS\Http\Request\Exception $e )
		{
			$spamCode = 0;
		}

		$action = NULL;
					
		if( $type == 'register' and $spamCode )
		{
			/* Log Request */
			\IPS\Db::i()->insert( 'core_spam_service_log', array(
															'log_date'		=> time(),
															'log_code'		=> $spamCode,
															'log_msg'		=> '',	// No value is returned unless it's a developer account making the call
															'email_address'	=> $this->email,
															'ip_address'	=> $this->ip_address
			) );
			
			/* Action to perform */
			$key = "spam_service_action_{$spamCode}";
			$action = \IPS\Settings::i()->$key;
			
			/* Perform Action */
			switch( $action )
			{
				/* Proceed with registration */
				case 1:
					break;
			
					/* Flag for admin approval */
				case 2:
					\IPS\Settings::i()->reg_auth_type = 'admin';
					break;
			
					/* Approve the account, but ban it */
				case 3:
					$this->temp_ban = -1;
					$this->members_bitoptions['bw_is_spammer'] = TRUE;
					break;
			
					/* Deny registration - we return the code and the controller is expected to show an error */
				case 4:
					break;
			}
		}

		return $action;
	}
	
	/**
	 * Member Sync
	 *
	 * @param	string	$method	Method
	 * @param	array	$params	Additional parameters to pass
	 * @return	void
	 */
	public function memberSync( $method, $params=array() )
	{
		/* Don't do this during an upgrade */
		if( \IPS\Dispatcher::hasInstance() AND \IPS\Dispatcher::i()->controllerLocation === 'setup' )
		{
			return;
		}

		foreach ( \IPS\Application::allExtensions( 'core', 'MemberSync', FALSE ) as $class )
		{
			if ( method_exists( $class, $method ) )
			{
				call_user_func_array( array( $class, $method ), array_merge( array( $this ), $params ) );
			}
		}
	}
			
	/**
	 * Merge
	 *
	 * @param	\IPS\Member	$otherMember	Member to merge with
	 * @return	void
	 */
	public function merge( \IPS\Member $otherMember )
	{
		if ( $this == $otherMember )
		{
			throw new \InvalidArgumentException( 'merge_self_error' );
		}
		
		/* Merge content */
		$otherMember->hideOrDeleteAllContent( 'merge', array( 'merge_with_id' => $this->member_id, 'merge_with_name' => $this->name ) );
		
		/* Let apps do their stuff */
		$this->memberSync( 'onMerge', array( $otherMember ) );

		/* Login handler callback */
		foreach ( \IPS\Login::handlers( TRUE ) as $k => $handler )
		{
			try
			{
				$handler->mergeAccounts( $this, $otherMember );
			}
			catch( \Exception $e ){}
		}
	}
	
	/**
	 * Add profile visitor
	 *
	 * @param   \IPS\Member $visitor	Member that viewed profile
	 * @return	void
	 */
	public function addVisitor( $visitor )
	{
		$visitors = json_decode( $this->pp_last_visitors, TRUE );
				
		/* If this member is already in the visitor list remove the entry so we can add back in the correct order */
		if( isset( $visitors[ $visitor->member_id ] ) )
		{
			unset( $visitors[ $visitor->member_id ] );
		}
		/* We want to limit to 5 members */
		else if ( count( $visitors ) >= 5 )
		{
			$visitors	= array_reverse( $visitors, TRUE );
			array_pop( $visitors );
			$visitors	= array_reverse( $visitors, TRUE );
		}
		
		/* Add the new entry */
		$visitors[ $visitor->member_id ] = time();
		
		/* Encode and save*/
		$this->pp_last_visitors = json_encode( $visitors );
		$this->save();
	}
	
	/**
	 * @brief	Posts Per Day Storage
	 */
	protected $_ppdLimit = NULL;
	
	/**
	 * Check posts per day to see if this member can post.
	 *
	 * @return	bool
	 */
	public function checkPostsPerDay()
	{
		/* Fetch our PPD limit - we should only need to do this once */
		if ( $this->_ppdLimit === NULL )
		{
			$this->_ppdLimit = $this->group['g_ppd_limit'];
		}
		/* We can't actually check guests as we can't store how often they have posted - simply counting content is not viable */
		if ( ! $this->member_id )
		{
			return TRUE;
		}
		
		/* Is there any limit at all? */
		if ( ! $this->_ppdLimit )
		{
			return TRUE;
		}
		
		$count	= $this->members_day_posts[0];
		$time	= $this->members_day_posts[1];
		
		/* Have we posted at all yet? */
		if ( ! $count OR ! $time )
		{
			return TRUE;
		}
		
		/* Are we beyond our 24 hours? */
		if ( $time AND $time < \IPS\DateTime::create()->sub( new \DateInterval( 'P1D' ) )->getTimestamp() )
		{
			/* Update member immediately */
			$this->members_day_posts = array( 0, 0 );
			$this->save();
			return TRUE;
		}
		
		/* Still within 24 hours... have we hit the limit? */
		if ( $count >= $this->_ppdLimit )
		{
			if ( $this->group['g_ppd_unit'] )
			{
				/* The limit may have been removed due to number of total posts or days since joining */
				if( $this->group['gbw_ppd_unit_type'] )
				{
					/* Days */
					if ( $this->joined->add( new \DateInterval( "P{$this->group['g_ppd_unit']}D" ) )->getTimestamp() < time() )
					{
						return TRUE;
					}
				}
				else
				{
					/* Posts */
					if ( $this->member_posts >= $this->group['g_ppd_unit'] )
					{
						return TRUE;
					}
				}
			}
			
			return FALSE;
		}
		
		/* Still here? */
		return TRUE;
	}

	/**
	 * @brief	Group Promotion Type
	 */
	protected $_groupPromotion = NULL;
	
	/**
	 * Check Group Promotion
	 *
	 * @return	void
	 */
	public function checkGroupPromotion()
	{
		/* If we should ignore promotions for this member, do so */
		if( $this->members_bitoptions['ignore_promotions'] )
		{
			return;
		}
		
		/* Just check the primary group, secondary groups should not prevent promoting */
		if( \IPS\Member\Group::load( $this->member_group_id )->g_promote_exclude )
		{
			return;
		}
		
		$ruleToUse = NULL;

		/* Loop over all group promotion rules and get the last one that matches us */
		foreach( \IPS\Member\GroupPromotion::roots() as $rule )
		{
			if( $rule->enabled and $rule->matches( $this ) )
			{
				$ruleToUse = $rule->id;
			}
		}

		/* If there's no rule, return now */
		if( $ruleToUse === NULL )
		{
			return;
		}

		/* If we matched a rule, get that rule now */
		$ruleToUse	= \IPS\Member\GroupPromotion::load( $ruleToUse );

		/* Set the primary and secondary groups as appropriate */
		$action = $ruleToUse->_actions;

		if( $action['primary_group'] AND $this->member_group_id != $action['primary_group'] )
		{
			try
			{
				$group = \IPS\Member\Group::load( $action['primary_group'] );
				$this->_groupPromotion = $ruleToUse->id;
				$this->member_group_id = $action['primary_group'];

				/* Reset profile completion flag to see if there are any items to complete now permissions are elevated */
				$this->members_bitoptions['profile_completed'] = 0;
			}
			catch ( \OutOfRangeException $e )
			{
				\IPS\Log::debug( 'Promotion ' .  $ruleToUse->id . ' tried to set not existing member group' );
			}
		}

		if( count( $action['secondary_group'] ) OR count( $action['secondary_remove'] ) )
		{
			$secondaryGroups = array_filter( explode( ',', $this->_data['mgroup_others'] ) );

			foreach ( $action['secondary_group'] as $key => $group )
			{
				try
				{
					$group = \IPS\Member\Group::load( $group );
				}
				catch ( \OutOfRangeException $e )
				{
					unset( $action['secondary_group'][$key] );
					\IPS\Log::debug( 'Promotion ' .  $ruleToUse->id . ' tried to set not existing member group' );
				}
			}
			if( count( $action['secondary_group'] ) )
			{
				if( array_diff( $action['secondary_group'], $secondaryGroups ) )
				{
					$this->_groupPromotion = $ruleToUse->id;
					$secondaryGroups = array_merge( $secondaryGroups, $action['secondary_group'] );
				}
			}

			if( count( $action['secondary_remove'] ) )
			{
				foreach( $action['secondary_remove'] as $groupToRemove )
				{
					while( in_array( $groupToRemove, $secondaryGroups ) )
					{
						$key = array_search( $groupToRemove, $secondaryGroups );

						unset( $secondaryGroups[ $key ] );
						$this->_groupPromotion = $ruleToUse->id;
					}
				}
			}

			$this->mgroup_others = implode( ',', array_unique( $secondaryGroups ) );
			
			/* Reset profile completion flag to see if there are any items to complete now permissions are elevated */
			$this->members_bitoptions['profile_completed'] = 0;
		}
	}

	/**
	 * Is the current user allowed to use the contact us form
	 *
	 * @return bool
	 */
	public function canUseContactUs()
	{
		try
		{
			$module = \IPS\Application\Module::get( 'core', 'contact' );
		}
		catch ( \OutOfRangeException $e )
		{
			return FALSE;
		}
		
		if ( !$this->canAccessModule( $module ) )
		{
			return FALSE;
		}

		/* If all groups have access, we can */
		if( \IPS\Settings::i()->contact_access != '*' )
		{
			/* Check member */
			$memberGroups	= array_merge( array( $this->member_group_id ), array_filter( explode( ',', $this->mgroup_others ) ) );
			$accessGroups	= explode( ',', \IPS\Settings::i()->contact_access );

			/* Are we in an allowed group? */
			if( count( array_intersect( $accessGroups, $memberGroups ) ) )
			{
				return TRUE;
			}
			else
			{
				return FALSE;
			}
		}
		return TRUE;
	}
	
	/**
	 * Perform a database update on all members
	 *
	 * Typically, updating the entire table locks the table which makes other queries stack up.
	 * On busy sites this is a real problem. We mitigate this by updating in batches via a background task
	 *
	 * We replace if the fields we want to update are the exact same on a subsequent call to this method, so if you had ( 'skin' => 2 ) and then ( 'skin' => 3 ),
	 * the row matching 'skin' => 2 will be removed from the queue table and the 'skin' => 3 row will replace it. This is to ensure that if you update the same fields while
	 * an existing task is running, it will update the members with the latest data.
	 *
	 * @note This task can be processing or waiting to process and the member can still change the value, so it could be possible for the member to set a 'skin' parameter and then have this
	 * overwritten when this task processes.
	 *
	 * @param	array	$update		array( field => value ) pairs to be used directly in a \IPS\Db::i()->update( 'core_members', $update ) query
	 * @param	int		$severity	Severity level. 1 being highest, 5 lowest
	 * @return	void
	 */
	public static function updateAllMembers( $update, $severity=3 )
	{
		\IPS\Task::queue( 'core', 'UpdateMembers', array( 'update' => $update ), $severity, array( 'update' ) );
	}

	/**
	 * Invalidate the current "create menu" key
	 *
	 * @return	void
	 */
	public static function clearCreateMenu()
	{
		$newKey = uniqid();
		\IPS\Settings::i()->changeValues( array( 'member_menu_create_key' => $newKey ) );
	}
	
	/**
	 * Invalidate all sessions and auto-login-keys. Called after the user changes their email address or password.
	 *
	 * @param	bool|string	$frontEndSessions	Boolean value indicating if front-end sessions should be cleared, or a string containing a session ID to wipe all except that one
	 * @param	bool|string	$acpSessions		Boolean value indicating if acp sessions should be cleared, or a string containing a session ID to wipe all except that one
	 * @param	bool		$loginKeys			Boolean value indicating if login keys (used for "Remember Me" logins) should be wiped
	 * @return	void
	 */
	public function invalidateSessionsAndLogins( $frontEndSessions=TRUE, $acpSessions=TRUE, $loginKeys=TRUE )
	{
		/* Terminate any active sessions */
		if ( $frontEndSessions !== FALSE )
		{
			$where = array( array( 'member_id=?', $this->member_id ) );
			if ( is_string( $frontEndSessions ) )
			{
				$where[] = array( 'id<>?', $frontEndSessions );
			}
			\IPS\Db::i()->delete( 'core_sessions', $where );
		}
		if ( $acpSessions !== FALSE )
		{
			$where = array( array( 'session_member_id=?', $this->member_id ) );
			if ( is_string( $acpSessions ) )
			{
				$where[] = array( 'session_id<>?', $acpSessions );
			}
			\IPS\Db::i()->delete( 'core_sys_cp_sessions', $where );
		}
		
		/* Wipe login keys to stop "Remember Me" cookies automatically logging us in */
		if ( $loginKeys )
		{
			\IPS\Db::i()->update( 'core_members_known_devices', array( 'login_key' => NULL ), array( 'member_id=?', $this->member_id ) );
		}
		
		/* Invalidate any pending "Forgot Password" or 2FA recovery emails, because they provide a doorway into accessing the account */
		\IPS\Db::i()->delete( 'core_validating', array( 'member_id=? AND ( lost_pass=1 OR forgot_security=1 )', $this->member_id ) );
	}
	
	/* !Registration/Validation */
	
	/**
	 * Call after completed registration to send email for validation if required or flag for admin validation
	 *
	 * @param	bool	$noEmailValidationRequired	If the user's email is implicitly trusted (for example, provided by a third party), set this to TRUE to bypass email validation
	 * @param	bool	$doNotDelete				If TRUE, the account will not be deleted in the normal cleanup of unvalidated accounts. Used for accounts created in Commerce checkout.
	 * @return	void
	 */
	public function postRegistration( $noEmailValidationRequired = FALSE, $doNotDelete = FALSE )
	{
		/* Work out validation type */
		$validationType = \IPS\Settings::i()->reg_auth_type;
		if ( $noEmailValidationRequired )
		{
			switch ( $validationType )
			{
				case 'user':
					$validationType = 'none';
					break;
				case 'admin_user':
					$validationType = 'admin';
					break;
			}
		}
		
		/* Validation */
		if ( $validationType != 'none' )
		{
			/* Set the flag */
			$this->members_bitoptions['validating'] = TRUE;
			$this->save();
			
			/* Prevent duplicates from double clicking, etc */
			\IPS\Db::i()->delete( 'core_validating', array( 'member_id=? and new_reg=1', $this->member_id ) );
			
			/* Insert a record */
			$vid = md5( $this->members_pass_hash . \IPS\Login::generateRandomString() );
			\IPS\Db::i()->insert( 'core_validating', array(
				'vid'		   	=> $vid,
				'member_id'	 	=> $this->member_id,
				'entry_date'	=> time(),
				'new_reg'	   	=> 1,
				'ip_address'	=> $this->ip_address,
				'spam_flag'	 	=> ( $this->members_bitoptions['bw_is_spammer'] ) ?: FALSE,
				'user_verified' => ( $validationType == 'admin' ) ?: FALSE,
				'email_sent'	=> ( $validationType != 'admin' ) ? time() : NULL,
				'do_not_delete'	=> $doNotDelete
			) );
			
			
			/* Send email for validation */
			if ( $validationType != 'admin' )
			{
				\IPS\Email::buildFromTemplate( 'core', 'registration_validate', array( $this, $vid ), \IPS\Email::TYPE_TRANSACTIONAL )->send( $this );
			}
		}
		
		/* If no email-related validation is required, notify the incoming mail address */
		if( $validationType == 'none' or $validationType == 'admin' )
		{
			$this->_registrationNotifyAdmin();
		}
	}
	
	/**
	 * Send email to admin telling them the user has registered
	 * If no validation, or admin-only validation is enabled: this is called immediately after registration
	 * If email-only or email-and-admin validation is enabled: this is called after the user has validated their email address
	 * i.e. in all cases, it is when the admin needs to approve the account, or, if no admin validation is enabled, when the account is ready to use
	 *
	 * @return	void
	 */
	protected function _registrationNotifyAdmin()
	{
		if( \IPS\Settings::i()->new_reg_notify )
		{
			try
			{
				$values = \IPS\Db::i()->select( '*', 'core_pfields_content', array( 'member_id=?', $this->member_id ) )->first();
			}
			catch ( \UnderflowException $e )
			{
				$values = array();
			}
			
			$profileFields = array();
			foreach ( \IPS\core\ProfileFields\Field::fields( $values, \IPS\core\ProfileFields\Field::REG ) as $group => $fields )
			{
				foreach ( $fields as $id => $field )
				{
					if ( $field instanceof \IPS\Helpers\Form\Address )
					{
						$profileFields[ "field_{$id}" ] = (string) $field->value;
					}
					else
					{
						$profileFields[ "field_{$id}" ] = $field::stringValue( $field->value );
					}
				}
			}
			\IPS\Email::buildFromTemplate( 'core', 'registration_notify', array( $this, $profileFields ), \IPS\Email::TYPE_LIST )->send( \IPS\Settings::i()->email_in );
		}
	}
	
	/**
	 * Email Validation Confirmed
	 *
	 * @param	$record		validating record
	 * @return	void
	 */
	public function emailValidationConfirmed( $record )
	{
		/* Notify the admin only if they have not been notified before */
		if( !$record['user_verified'] )
		{
			$this->_registrationNotifyAdmin();
		}
		
		/* If admin validation is required, set the flag */
		if ( \IPS\Settings::i()->reg_auth_type == 'admin_user' )
		{
			\IPS\Db::i()->update( 'core_validating', array( 'user_verified' => TRUE ), array( 'member_id=?', $this->member_id ) );
		}
		
		/* Otherwise, validation is complete */
		else
		{
			$this->validationComplete();
		}		
	}
	
	/**
	 * Final Validation Complete
	 * If no validation is enabled: this is never called
	 * If email-only validation is enabled: this is called after the user has validated their email address or if the admin manually validates the account
	 * If admin (including email and admin) validation is enabled: this is called after the admin has validated the account
	 *
	 * @param	array	$record	Row from core_validating
	 * @return	void
	 */
	public function validationComplete()
	{
		/* Send a success email */
		try
		{
			\IPS\Email::buildFromTemplate( 'core', 'registration_complete', array( $this ), \IPS\Email::TYPE_TRANSACTIONAL )->send( $this );
		}
		catch( \ErrorException $e ) { }
		
		/* Delete rows */
		\IPS\Db::i()->delete( 'core_validating', array( 'member_id=?', $this->member_id ) );
		
		/* Reset the flag */
		$this->members_bitoptions['validating'] = FALSE;
		$this->save();
		
		/* Sync */
		$this->memberSync( 'onValidate' );

		/* Login handler callback */
		foreach ( \IPS\Login::handlers( TRUE ) as $k => $handler )
		{
			try
			{
				$handler->validateAccount( $this );
			}
			catch( \Exception $e ){}
		}
	}

	/**
	 * Get how often the member changed his name
	 *
	 * @return bool
	 */
	public function hasNameChanges()
	{
		try
		{
			return \IPS\Db::i()->select( 'count(*)', 'core_member_history', array( 'log_member=? AND log_app=? AND log_type=?', $this->member_id, 'core', 'display_name' ) )->first();
		}
		catch ( \UnderflowException $e )
		{
			return FALSE;
		}
	}
	
	/**
	 * Linked profile sync services
	 *
	 * @return array
	 */
	public function connectedProfileSyncServices()
	{
		$services = array();

		foreach( \IPS\core\ProfileSync\ProfileSyncAbstract::services() as $service )
		{
			$obj = new $service( $this );
			if ( $obj->connected() )
			{
				$services[] = $obj::$icon;
			}
		}

		return $services;
	}

	/**
	 * Can this member be ignored?
	 *
	 * @return bool
	 */
	public function canBeIgnored()
	{
		if ( !\IPS\Settings::i()->ignore_system_on )
		{
			return FALSE;
		}
		
		if( !$this->member_id )
		{
			return FALSE;
		}

		if ( $this->modPermission() AND !$this->modPermission( 'can_moderator_be_ignored' ) )
		{
			return FALSE;
		}

		if ( $this->group['gbw_cannot_be_ignored'] )
		{
			return FALSE;
		}

		if ( $this->member_id == \IPS\Member::loggedIn()->member_id )
		{
			return FALSE;
		}

		return TRUE;
	}

	/**
	 * Log Member Action
	 *
	 * @param	mixed		$app			The application action applies to
	 * @param	string		$type			Log type
	 * @param	mixed		$extra			Any extra data for the type
	 * @param	mixed		$by				The member performing the action. NULL for currently logged in member or FALSE for no member
	 * 
	 * @return	void
	 */
	public function logHistory( $app, $type, $extra=NULL, $by=NULL )
	{
		if ( $this->member_id )
		{
			if ( $by === NULL )
			{
				$by = ( !\IPS\Dispatcher::hasInstance() ) ? NULL : \IPS\Member::loggedIn();
			}

			\IPS\Db::i()->insert( 'core_member_history', array(
				'log_app'			=> $app,
				'log_member'		=> (int) $this->member_id,
				'log_by'			=> $by ? $by->member_id : NULL,
				'log_type'			=> $type,
				'log_data'			=> json_encode( $extra ),
				'log_date'			=> microtime( TRUE ),
				'log_ip_address'	=> \IPS\Request::i()->ipAddress()
			) );
		}
	}
	
	/* !Top Members */
	
	const TOP_MEMBERS_OVERVIEW = 1;
	const TOP_MEMBERS_FILTERS = 2;
	
	/**
	 * @brief	Custom count for a top member resu;t
	 */
	public $_customCount = NULL;
	
	/**
	 * Get available Top Members options
	 *
	 * @param	int		$filter		See TOP_MEMBERS_* constants
	 * @return	array
	 */
	public static function topMembersOptions( $filter = 0 )
	{
		$filters = array(
			'pp_reputation_points' => \IPS\Member::loggedIn()->language()->addToStack('leaderboard_tab_reputation'),
			'member_posts' => \IPS\Member::loggedIn()->language()->addToStack('leaderboard_tab_posts')
		);
		
		foreach ( \IPS\Application::allExtensions( 'core', 'ContentRouter', TRUE ) as $object )
		{
			foreach( $object->classes as $item )
			{
				$commentClass = NULL;
				if ( isset( $item::$commentClass ) )
				{
					$commentClass = $item::$commentClass;
				}
				
				if ( $item::$firstCommentRequired and isset( $commentClass::$databaseColumnMap['author'] ) )
				{
					$filters[ $commentClass ] = \IPS\Member::loggedIn()->language()->addToStack('leaderboard_tab_x', NULL, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->addToStack("{$commentClass::$title}_pl_lc") ) ) );
				}
				elseif ( isset( $item::$databaseColumnMap['author'] ) )
				{
					$filters[ $item ] = \IPS\Member::loggedIn()->language()->addToStack('leaderboard_tab_x', NULL, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->addToStack("{$item::$title}_pl_lc") ) ) );
				}
			}
		}
		
		if ( $filter )
		{
			$available = $filter === static::TOP_MEMBERS_OVERVIEW ? \IPS\Settings::i()->reputation_top_members_overview : \IPS\Settings::i()->reputation_top_members_filters;
			if ( $available != '*' )
			{
				$available = explode( ',', $available );
				$filters = array_filter( $filters, function( $k ) use ( $available ) {
					return in_array( $k, $available );
				}, ARRAY_FILTER_USE_KEY );
			}
		}
		
		return $filters;
	}
	
	/**
	 * Get top members for a particular type
	 *
	 * @param	string	$type	The type (as returned by topMembersOptions())
	 * @param	int		$limit	Number to get
	 * @return	\Traversable
	 */
	public static function topMembers( $type, $limit )
	{
		if ( in_array( $type, array( 'pp_reputation_points', 'member_posts' ) ) )
		{
			$where = array(
				array( "name IS NOT NULL" ),
				array( "name != ''" ),
				array( "temp_ban != -1" ),
				array( "email != ''" ),
				array( $type . '>0' )
			);
			$orderBy = $type . ' DESC';
			
			return new \IPS\Patterns\ActiveRecordIterator( \IPS\Db::i()->select( '*', 'core_members', $where, $orderBy, $limit ), 'IPS\Member' );
		}
		else
		{
			$storeKey = 'store_' . str_replace( '\\', '-', $type );
			$stored = isset( \IPS\Data\Store::i()->$storeKey ) ? \IPS\Data\Store::i()->$storeKey : NULL;
			
			if ( ! $stored or ( ( time() - $stored['time'] ) > 300 ) )
			{
			
				$contentWhere = array( array( $type::$databasePrefix . $type::$databaseColumnMap['author'] . '<>?', 0 ) );
				if ( isset( $type::$databaseColumnMap['hidden'] ) )
				{
					$contentWhere[] = array( $type::$databasePrefix . $type::$databaseColumnMap['hidden'] . '=0' );
				}
				else if ( isset( $type::$databaseColumnMap['approved'] ) )
				{
					$contentWhere[] = array( $type::$databasePrefix . $type::$databaseColumnMap['approved'] . '=1' );
				}
				
				$authorField = $type::$databasePrefix . $type::$databaseColumnMap['author'];
				$members = array();
				foreach( \IPS\Db::i()->select( 'COUNT(*) as count, ' . $type::$databaseTable . '.' . $authorField, $type::$databaseTable, $contentWhere, 'count DESC', $limit, $authorField ) as $row )
				{
					$members[ $row[ $authorField ] ] = $row['count'];
				}
				
				/* Make sure these members actually exist */
				$memberIds = array();
				foreach( \IPS\Db::i()->select( 'member_id', 'core_members', \IPS\Db::i()->in( 'member_id', array_keys( $members ) ) ) AS $member_id )
				{
					$memberIds[ $member_id ] = $members[ $member_id ];
				}
			
				\IPS\Data\Store::i()->$storeKey = array( 'time' => time(), 'memberIds' => $memberIds );
				$stored = \IPS\Data\Store::i()->$storeKey;
			}

			$results = array();
			foreach ( new \IPS\Patterns\ActiveRecordIterator( \IPS\Db::i()->select( '*', 'core_members', \IPS\Db::i()->in( 'member_id', array_keys( $stored['memberIds'] ) ), NULL, $limit ), 'IPS\Member' ) as $member )
			{
				$member->_customCount = $stored['memberIds'][ $member->member_id ];
				$results[ $member->member_id ] = $member;
			}
			
			/* Sort by custom value DESC */
			uasort( $results, function( $a, $b ) {
				return ( $a->_customCount == $b->_customCount ) ? 0 : ( ( $a->_customCount < $b->_customCount ) ? 1 : -1 );
			});
			
			return $results;
		}
	}
	
	/* !Profile Completion */
	
	/**
	 * @brief	Profile Completion Cache
	 */
	public $_profileCompletion = NULL;
	
	/**
	 * Returns suggested profile items
	 *
	 * @return	array
	 */
	public function profileCompletion()
	{
		if ( $this->_profileCompletion === NULL )
		{
			$this->_profileCompletion = array();
			$this->_profileCompletion['required'] = array();
			$this->_profileCompletion['suggested'] = array();
			
			if ( $this->member_id AND !$this->members_bitoptions['profile_completed'] )
			{
				foreach( Member\ProfileStep::loadAll() AS $id => $step )
				{
					$this->_profileCompletion[ ( $step->required ) ? 'required' : 'suggested' ][ $step->id ] = $step->completed( $this );
				}
			}
		}
		
		return $this->_profileCompletion;
	}
	
	/**
	 * Profile Completion Percentage
	 *
	 * @return	\IPS\Math\Number
	 */
	public function profileCompletionPercentage()
	{
		if ( $this->members_bitoptions['profile_completed'] )
		{
			return new \IPS\Math\Number( '100' );
		}
		
		$total	= 0;
		$done	= 0;
		
		/* Check Required Ones */
		$completion = $this->profileCompletion();
		foreach( $completion['required'] AS $key => $value )
		{
			$total++;
			if ( $value )
			{
				$done++;
			}
		}
		
		/* Now Suggested */
		foreach( $completion['suggested'] AS $key => $value )
		{
			$total++;
			if ( $value )
			{
				$done++;
			}
		}
		
		/* Are we actually done? */
		if ( $done === $total AND $this->members_bitoptions['profile_completed'] === FALSE )
		{
			$this->members_bitoptions['profile_completed'] = TRUE;
			$this->save();
		}
		
		if ( $this->members_bitoptions['profile_completed'] )
		{
			return new \IPS\Math\Number( '100' );
		}
		else
		{
			return new \IPS\Math\Number( (string) round( 100 / $total * $done ) );
		}
	}
	
	/**
	 * Next Profile Step
	 *
	 * @return	\IPS\Member\ProfileStep|NULL
	 */
	public function nextProfileStep()
	{
		if ( $this->members_bitoptions['profile_completed'] )
		{
			return NULL;
		}
		
		$completed = $this->profileCompletion();
		
		if ( !count( $completed['suggested'] ) )
		{
			return NULL;
		}
		
		$nextStep = NULL;
		foreach( $completed['suggested'] AS $id => $complete )
		{
			if ( !$complete )
			{
				$nextStep = \IPS\Member\ProfileStep::load( $id );
				break;
			}
		}
		
		return $nextStep;
	}

	/**
	 * Determine if the member can use signatures
	 *
	 * @return bool
	 */
	public function canEditSignature()
	{
		/* If signatures are globally disabled, we can't edit them */
		if( !\IPS\Settings::i()->signatures_enabled )
		{
			return FALSE;
		}

		/* Are they enabled for our group? */
		$sigLimits = explode( ":", $this->group['g_signature_limits'] );

		if( $sigLimits[0] )
		{
			return FALSE;
		}

		/* Are there post count or day restrictions? */
		if( $this->group['g_sig_unit'] )
		{
			if( $this->group['gbw_sig_unit_type'] )
			{
				if ( $this->joined->diff( \IPS\DateTime::create() )->days < $this->group['g_sig_unit'] )
				{
					return FALSE;
				}
			}
			else
			{
				if ( $this->member_posts < $this->group['g_sig_unit'] )
				{
					return FALSE;
				}
			}
		}

		return TRUE;
	}
}