<?php

/**
 * Model for users.
 *
 * @package XenForo_Users
 */
class XenForo_Model_User extends XenForo_Model
{
	const FETCH_USER_PROFILE     = 0x01;
	const FETCH_USER_OPTION      = 0x02;
	const FETCH_USER_PRIVACY     = 0x04;
	const FETCH_USER_PERMISSIONS = 0x08;
	const FETCH_LAST_ACTIVITY    = 0x10;

	/**
	 * Quick constant for fetching, profile, option, and privacy data.
	 *
	 * @var integer
	 */
	const FETCH_USER_FULL        = 0x07;

	/**
	 * Special value to use for a permanent ban
	 *
	 * @var integer
	 */
	const PERMANENT_BAN = 0;

	public static $defaultGuestGroupId = 1;
	public static $defaultRegisteredGroupId = 2;
	public static $defaultAdminGroupId = 3;
	public static $defaultModeratorGroupId = 4;

	public static $guestPermissionCombinationId = 1;

	/**
	 * Stores the unserialized value of xf_user.ignored for each user that has been inspected
	 *
	 * @var array [user id] => [ignored user id] => ignored user name
	 */
	protected $_ignoreCache = array();

	/**
	 * Simple way to update user data fields.
	 *
	 * @param integer|array $userId|$user
	 * @param array|string Either the name of a single field, or an array of field-name => field-value pairs
	 * @param mixed If the previous parameter is a string, use this as the field value
	 *
	 * @return XenForo_DataWriter_User
	 */
	public function update($user, $field, $value = null)
	{
		$userId = $this->getUserIdFromUser($user);

		$writer = XenForo_DataWriter::create('XenForo_DataWriter_User');
		$writer->setExistingData($userId);

		if ($value === null)
		{
			if (is_array($field))
			{
				$writer->bulkSet($field);
			}
		}
		else if (is_string($field))
		{
			$writer->set($field, $value);
		}

		$writer->save();

		return $writer;
	}

	/**
	 * Fetches the user_id index from a user record
	 *
	 * @param integer|array $userId|$user
	 *
	 * @return integer User ID
	 */
	public static function getUserIdFromUser($user)
	{
		if (is_scalar($user))
		{
			return $user;
		}

		if (is_array($user) && isset($user['user_id']))
		{
			return $user['user_id'];
		}

		throw new XenForo_Exception('Unable to derive User ID from provided parameters.');
		return false;
	}

	/**
	 * Checks to see if the input string *might* be an email address - contains '@' after its first character
	 *
	 * @param String $email
	 *
	 * @return boolean
	 */
	public function couldBeEmail($email)
	{
		if (strlen($email) < 1)
		{
			return false;
		}

		return (strpos($email, '@', 1) !== false);
	}

	/**
	 * Gets all users. Can be restricted to valid users only with the
	 * validOnly fetch option.
	 *
	 * @param array $fetchOptions User fetch options
	 *
	 * @return array Format: [user id] => user info
	 */
	public function getAllUsers(array $fetchOptions = array())
	{
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);
		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');
		$whereClause = (!empty($fetchOptions['validOnly']) ? 'WHERE user.user_state = \'valid\' AND user.is_banned = 0' : '');

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT user.*
					' . $joinOptions['selectFields'] . '
				FROM xf_user AS user
				' . $joinOptions['joinTables'] . '
				' . $whereClause . '
				' . $orderClause . '
			', $limitOptions['limit'], $limitOptions['offset']
		), 'user_id');
	}

	/**
	 * Returns user records based on a list of usernames.
	 *
	 * @param array $usernames
	 * @param array $fetchOptions User fetch options
	 * @param array $invalidNames Returns a list of usernames that could not be found
	 *
	 * @return array Format: [user id] => info
	 */
	public function getUsersByNames(array $usernames, array $fetchOptions = array(), &$invalidNames = array())
	{
		$usernames = array_map('trim', $usernames);
		foreach ($usernames AS $key => $username)
		{
			if ($username === '')
			{
				unset($usernames[$key]);
			}
		}

		$invalidNames = array();

		if (!$usernames)
		{
			return array();
		}

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);
		$validOnlyClause = (!empty($fetchOptions['validOnly']) ? 'AND user.user_state = \'valid\' AND user.is_banned = 0' : '');

		$users = $this->fetchAllKeyed('
			SELECT user.*
				' . $joinOptions['selectFields'] . '
			FROM xf_user AS user
			' . $joinOptions['joinTables'] . '
			WHERE user.username IN (' . $this->_getDb()->quote($usernames) . ')
				' . $validOnlyClause . '
		', 'user_id');

		if (count($users) != count($usernames))
		{
			$usernamesLower = array_map('strtolower', $usernames);
			$invalidNames = $usernames;

			foreach ($users AS $user)
			{
				do
				{
					$foundKey = array_search(strtolower($user['username']), $usernamesLower);
					if ($foundKey !== false)
					{
						unset($invalidNames[$foundKey]);
						unset($usernamesLower[$foundKey]);
					}
				}
				while ($foundKey !== false);
			}
		}

		return $users;
	}

	/**
	 * Get users with specified user IDs.
	 *
	 * @param array $userIds
	 * @param array $fetchOptions
	 *
	 * @return array Format: [user id] => user info
	 */
	public function getUsersByIds(array $userIds, array $fetchOptions = array())
	{
		if (!$userIds)
		{
			return array();
		}

		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->fetchAllKeyed('
				SELECT user.*
					' . $joinOptions['selectFields'] . '
				FROM xf_user AS user
				' . $joinOptions['joinTables'] . '
				WHERE user.user_id IN (' . $this->_getDb()->quote($userIds) . ')
				' . $orderClause . '
		', 'user_id');
	}

	/**
	 * Return all users logged from a particular IP address
	 *
	 * @param string $ip
	 * @param array $fetchOptions
	 *
	 * @return array Format: [user id] => user info
	 */
	public function getUsersByIp($ip, array $fetchOptions = array())
	{
		if (!$ip)
		{
			return array();
		}

		$ip = XenForo_Helper_Ip::convertIpStringToBinary($ip);
		if (!$ip)
		{
			return array();
		}

		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->fetchAllKeyed('
			SELECT user.*, ip.ip, MAX(ip.log_date) AS log_date
				' . $joinOptions['selectFields'] . '
				FROM xf_ip AS ip
				INNER JOIN xf_user AS user ON
					(user.user_id = ip.user_id)
				' . $joinOptions['joinTables'] . '
				WHERE ip.ip = ?
				GROUP BY ip.user_id
				' . $orderClause . '
		', 'user_id', $ip);
	}

	/**
	 * Return all users logged from a range of binary IPs
	 *
	 * @param string $lowerBound Binary IP range start
	 * @param string $upperBound Binary IP range end
	 * @param array $fetchOptions
	 *
	 * @return array Format: [user id] => user info
	 */
	public function getUsersByIpRange($lowerBound, $upperBound, array $fetchOptions = array())
	{
		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->fetchAllKeyed('
			SELECT user.*, ip.ip, MAX(ip.log_date) AS log_date
				' . $joinOptions['selectFields'] . '
				FROM xf_ip AS ip
				INNER JOIN xf_user AS user ON
					(user.user_id = ip.user_id)
				' . $joinOptions['joinTables'] . '
				WHERE ip.ip >= ? AND ip.ip <= ? AND LENGTH(ip.ip) = ?
				GROUP BY ip.user_id
				' . $orderClause . '
		', 'user_id', array($lowerBound, $upperBound, strlen($lowerBound)));
	}

	/**
	 * Gets users that match the specified conditions.
	 *
	 * @param array $conditions
	 * @param array $fetchOptions
	 *
	 * @return array Format: [user id] => user info
	 */
	public function getUsers(array $conditions, array $fetchOptions = array())
	{
		$whereClause = $this->prepareUserConditions($conditions, $fetchOptions);

		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');
		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT user.*
					' . $joinOptions['selectFields'] . '
				FROM xf_user AS user
				' . $joinOptions['joinTables'] . '
				WHERE ' . $whereClause . '
				' . $orderClause . '
			', $limitOptions['limit'], $limitOptions['offset']
		), 'user_id');
	}

	/**
	 * Gets the user Ids that match the specified conditions.
	 *
	 * @param array $conditions
	 * @param integer $start
	 * @param integer $limit
	 *
	 * @return array
	 */
	public function getUserIds(array $conditions, $start = 0, $limit = 0)
	{
		$fetchOptions = array();
		$whereClause = $this->prepareUserConditions($conditions, $fetchOptions);

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->_getDb()->fetchCol($this->limitQueryResults('
			SELECT user.user_id
			FROM xf_user AS user
			' . $joinOptions['joinTables'] . '
			WHERE ' . $whereClause . '
				AND user.user_id > ' . intval($start) . '
			ORDER BY user.user_id
		', $limit));
	}

	/**
	 * Gets the count of users that match the specified conditions.
	 *
	 * @param array $conditions
	 *
	 * @return integer
	 */
	public function countUsers(array $conditions)
	{
		$fetchOptions = array();
		$whereClause = $this->prepareUserConditions($conditions, $fetchOptions);

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->_getDb()->fetchOne('
			SELECT COUNT(*)
			FROM xf_user AS user
			' . $joinOptions['joinTables'] . '
			WHERE ' . $whereClause
		);
	}

	public function getBirthdayUsers($month, $day, array $conditions = array(), array $fetchOptions = array(), $publicOnly = true)
	{
		$this->addFetchOptionJoin($fetchOptions, self::FETCH_USER_OPTION);
		$this->addFetchOptionJoin($fetchOptions, self::FETCH_USER_PROFILE);

		$whereClause = $this->prepareUserConditions($conditions, $fetchOptions);

		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');
		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT user.*
					' . $joinOptions['selectFields'] . '
				FROM xf_user AS user
				' . $joinOptions['joinTables'] . '
				WHERE user_profile.dob_month = ?
					AND user_profile.dob_day = ?
					' . ($publicOnly ? ' AND user_option.show_dob_date = 1' : '') . '
					AND (' . $whereClause . ')
				' . $orderClause . '
			', $limitOptions['limit'], $limitOptions['offset']
		), 'user_id', array($month, $day));
	}

	/**
	 * Gets the specified user by ID.
	 *
	 * @param integer $userId
	 * @param array $fetchOptions User fetch options
	 *
	 * @return array|false
	 */
	public function getUserById($userId, array $fetchOptions = array())
	{
		if (empty($userId))
		{
			return false;
		}

		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->_getDb()->fetchRow('
			SELECT user.*
				' . $joinOptions['selectFields'] . '
			FROM xf_user AS user
			' . $joinOptions['joinTables'] . '
			WHERE user.user_id = ?
		', $userId);
	}

	/**
	 * Returns a user record based on an input username
	 *
	 * @param string $username
	 * @param array $fetchOptions User fetch options
	 *
	 * @return array|false
	 */
	public function getUserByName($username, array $fetchOptions = array())
	{
		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->_getDb()->fetchRow('
			SELECT user.*
				' . $joinOptions['selectFields'] . '
			FROM xf_user AS user
			' . $joinOptions['joinTables'] . '
			WHERE user.username = ?
		', $username);
	}

	/**
	 * Returns a user record based on an input email
	 *
	 * @param string $email
	 * @param array $fetchOptions User fetch options
	 *
	 * @return array|false
	 */
	public function getUserByEmail($email, array $fetchOptions = array())
	{
		$joinOptions = $this->prepareUserFetchOptions($fetchOptions);

		return $this->_getDb()->fetchRow('
			SELECT user.*
				' . $joinOptions['selectFields'] . '
			FROM xf_user AS user
			' . $joinOptions['joinTables'] . '
			WHERE user.email = ?
		', $email);
	}

	/**
	 * Returns a user record based on an input username OR email
	 *
	 * @param string $input
	 * @param array $fetchOptions User fetch options
	 *
	 * @return array|false
	 */
	public function getUserByNameOrEmail($input, array $fetchOptions = array())
	{
		if ($this->couldBeEmail($input))
		{
			if ($user = $this->getUserByEmail($input, $fetchOptions))
			{
				return $user;
			}
		}

		return $this->getUserByName($input, $fetchOptions);
	}

	/**
	 * Prepares join-related fetch options.
	 *
	 * @param array $fetchOptions
	 *
	 * @return array Containing 'selectFields' and 'joinTables' keys.
	 */
	public function prepareUserFetchOptions(array $fetchOptions)
	{
		$selectFields = '';
		$joinTables = '';

		if (!empty($fetchOptions['join']))
		{
			if ($fetchOptions['join'] & self::FETCH_USER_PROFILE)
			{
				$selectFields .= ',
					user_profile.*';
				$joinTables .= '
					LEFT JOIN xf_user_profile AS user_profile ON
						(user_profile.user_id = user.user_id)';
			}

			// TODO: optimise the join on user_option with serialization to user or user_profile
			if ($fetchOptions['join'] & self::FETCH_USER_OPTION)
			{
				$selectFields .= ',
					user_option.*';
				$joinTables .= '
					LEFT JOIN xf_user_option AS user_option ON
						(user_option.user_id = user.user_id)';
			}

			if ($fetchOptions['join'] & self::FETCH_USER_PRIVACY)
			{
				$selectFields .= ',
					user_privacy.*';
				$joinTables .= '
					LEFT JOIN xf_user_privacy AS user_privacy ON
						(user_privacy.user_id = user.user_id)';
			}

			if ($fetchOptions['join'] & self::FETCH_USER_PERMISSIONS)
			{
				$selectFields .= ',
					permission_combination.cache_value AS global_permission_cache';
				$joinTables .= '
					LEFT JOIN xf_permission_combination AS permission_combination ON
						(permission_combination.permission_combination_id = user.permission_combination_id)';
			}

			if ($fetchOptions['join'] & self::FETCH_LAST_ACTIVITY)
			{
				$selectFields .= ',
					IF (session_activity.view_date IS NULL, user.last_activity, session_activity.view_date) AS effective_last_activity,
					session_activity.view_date, session_activity.controller_name, session_activity.controller_action, session_activity.params, session_activity.ip';
				$joinTables .= '
					LEFT JOIN xf_session_activity AS session_activity ON
						(session_activity.user_id = user.user_id AND session_activity.unique_key = user.user_id)';
			}
		}

		if (isset($fetchOptions['followingUserId']))
		{
			$fetchOptions['followingUserId'] = intval($fetchOptions['followingUserId']);
			if ($fetchOptions['followingUserId'])
			{
				// note: quoting is skipped; intval'd above
				$selectFields .= ',
					IF(user_follow.user_id IS NOT NULL, 1, 0) AS following_' . $fetchOptions['followingUserId'];
				$joinTables .= '
					LEFT JOIN xf_user_follow AS user_follow ON
						(user_follow.user_id = user.user_id AND user_follow.follow_user_id = ' . $fetchOptions['followingUserId'] . ')';
			}
			else
			{
				$selectFields .= ',
					0 AS following_0';
			}
		}

		if (isset($fetchOptions['nodeIdPermissions']))
		{
			$fetchOptions['nodeIdPermissions'] = intval($fetchOptions['nodeIdPermissions']);
			$selectFields .= ',
				permission.cache_value AS node_permission_cache';
			$joinTables .= '
				LEFT JOIN xf_permission_cache_content AS permission
					ON (permission.permission_combination_id = user.permission_combination_id
						AND permission.content_type = \'node\'
						AND permission.content_id = ' . $fetchOptions['nodeIdPermissions'] .')';
		}

		if (!empty($fetchOptions['customFields']) && is_array($fetchOptions['customFields']))
		{
			foreach ($fetchOptions['customFields'] AS $customFieldId => $value)
			{
				if ($value === '' || (is_array($value) && !$value))
				{
					continue;
				}

				$isExact = !empty($fetchOptions['customFieldsExact'][$customFieldId]);
				$customFieldId = preg_replace('/[^a-z0-9_]/i', '', $customFieldId);
				$selectFields .= ", user_field_value_$customFieldId.field_value AS custom_field_$customFieldId";

				if ($value === true)
				{
					$joinTables .= "
						LEFT JOIN xf_user_field_value AS user_field_value_$customFieldId ON
							(user_field_value_$customFieldId.user_id = user.user_id
							AND user_field_value_$customFieldId.field_id = " . $this->_getDb()->quote($customFieldId) . ")";
				}
				else
				{
					$possibleValues = array();
					foreach ((array)$value AS $possible)
					{
						if ($isExact)
						{
							$possibleValues[] = "user_field_value_$customFieldId.field_value = " . $this->_getDb()->quote($possible);
						}
						else
						{
							$possibleValues[] = "user_field_value_$customFieldId.field_value LIKE " . XenForo_Db::quoteLike($possible, 'lr');
						}
					}

					$joinTables .= "
						INNER JOIN xf_user_field_value AS user_field_value_$customFieldId ON
							(user_field_value_$customFieldId.user_id = user.user_id
							AND user_field_value_$customFieldId.field_id = " . $this->_getDb()->quote($customFieldId) . "
							AND (" . implode(' OR ', $possibleValues) . "))";
				}
			}
		}

		return array(
			'selectFields' => $selectFields,
			'joinTables'   => $joinTables
		);
	}

	/**
	 * Prepares a set of conditions to select users against.
	 *
	 * @param array $conditions List of conditions. (TODO: make list)
	 * @param array $fetchOptions The fetch options that have been provided. May be edited if criteria requires.
	 *
	 * @return string Criteria as SQL for where clause
	 */
	public function prepareUserConditions(array $conditions, array &$fetchOptions)
	{
		$db = $this->_getDb();
		$sqlConditions = array();

		if (!empty($conditions['user_id']))
		{
			if (is_array($conditions['user_id']))
			{
				$sqlConditions[] = 'user.user_id IN(' . $db->quote($conditions['user_id']) . ')';
			}
			else
			{
				$sqlConditions[] = 'user.user_id = ' . $db->quote($conditions['user_id']);
			}
		}

		if (!empty($conditions['username']))
		{
			if (is_array($conditions['username']))
			{
				$sqlConditions[] = 'user.username LIKE ' . XenForo_Db::quoteLike($conditions['username'][0], $conditions['username'][1], $db);
			}
			else
			{
				$sqlConditions[] = 'user.username LIKE ' . XenForo_Db::quoteLike($conditions['username'], 'lr', $db);
			}
		}

		// this is mainly for dynamically filtering a search that already matches user names
		if (!empty($conditions['username2']))
		{
			if (is_array($conditions['username2']))
			{
				$sqlConditions[] = 'user.username LIKE ' . XenForo_Db::quoteLike($conditions['username2'][0], $conditions['username2'][1], $db);
			}
			else
			{
				$sqlConditions[] = 'user.username LIKE ' . XenForo_Db::quoteLike($conditions['username2'], 'lr', $db);
			}
		}

		if (!empty($conditions['usernames']) && is_array($conditions['usernames']))
		{
			$sqlConditions[] = 'user.username IN (' . $db->quote($conditions['usernames']) . ')';
		}

		if (!empty($conditions['email']))
		{
			if (is_array($conditions['email']))
			{
				$sqlConditions[] = 'user.email LIKE ' . XenForo_Db::quoteLike($conditions['email'][0], $conditions['email'][1], $db);
			}
			else
			{
				$sqlConditions[] = 'user.email LIKE ' . XenForo_Db::quoteLike($conditions['email'], 'lr', $db);
			}
		}
		if (!empty($conditions['emails']) && is_array($conditions['emails']))
		{
			$sqlConditions[] = 'user.email IN (' . $db->quote($conditions['emails']) . ')';
		}

		if (!empty($conditions['user_group_id']))
		{
			if (is_array($conditions['user_group_id']))
			{
				$sqlConditions[] = 'user.user_group_id IN (' . $db->quote($conditions['user_group_id']) . ')';
			}
			else
			{
				$sqlConditions[] = 'user.user_group_id = ' . $db->quote($conditions['user_group_id']);
			}
		}

		if (isset($conditions['gender']))
		{
			if (is_array($conditions['gender']))
			{
				if ($conditions['gender'])
				{
					$sqlConditions[] = 'user.gender IN (' . $db->quote($conditions['gender']) . ')';
				}
			}
			else
			{
				$sqlConditions[] = 'user.gender = ' . $db->quote($conditions['gender']);
			}
		}

		if (!empty($conditions['secondary_group_ids']))
		{
			if (is_array($conditions['secondary_group_ids']))
			{
				$groupConds = array();
				foreach ($conditions['secondary_group_ids'] AS $groupId)
				{
					$groupConds[] = 'FIND_IN_SET(' . $db->quote($groupId) . ', user.secondary_group_ids)';
				}
				$sqlConditions[] = '(' . implode(' OR ', $groupConds) . ')';
			}
			else
			{
				$sqlConditions[] = 'FIND_IN_SET(' . $db->quote($conditions['secondary_group_ids']) . ', user.secondary_group_ids)';
			}
		}

		if (!empty($conditions['not_secondary_group_ids']))
		{
			if (is_array($conditions['not_secondary_group_ids']))
			{
				$groupConds = array();
				foreach ($conditions['not_secondary_group_ids'] AS $groupId)
				{
					$groupConds[] = 'FIND_IN_SET(' . $db->quote($groupId) . ', user.secondary_group_ids) = 0';
				}
				$sqlConditions[] = '(' . implode(' AND ', $groupConds) . ')';
			}
			else
			{
				$sqlConditions[] = 'FIND_IN_SET(' . $db->quote($conditions['not_secondary_group_ids']) . ', user.secondary_group_ids) = 0';
			}
		}

		if (isset($conditions['no_secondary_group_ids']))
		{
			if ($conditions['no_secondary_group_ids'])
			{
				$sqlConditions[] = "user.secondary_group_ids = ''";
			}
			else
			{
				$sqlConditions[] = "user.secondary_group_ids <> ''";
			}
		}

		if (!empty($conditions['last_activity']) && is_array($conditions['last_activity']))
		{
			$sqlConditions[] = $this->getCutOffCondition("user.last_activity", $conditions['last_activity']);
		}

		if (!empty($conditions['register_date']) && is_array($conditions['register_date']))
		{
			$sqlConditions[] = $this->getCutOffCondition("user.register_date", $conditions['register_date']);
		}

		if (!empty($conditions['message_count']) && is_array($conditions['message_count']))
		{
			$sqlConditions[] = $this->getCutOffCondition("user.message_count", $conditions['message_count']);
		}

		if (!empty($conditions['trophy_points']) && is_array($conditions['trophy_points']))
		{
			$sqlConditions[] = $this->getCutOffCondition("user.trophy_points", $conditions['trophy_points']);
		}

		if (!empty($conditions['user_state']) && $conditions['user_state'] !== 'any')
		{
			if (is_array($conditions['user_state']))
			{
				$sqlConditions[] = 'user.user_state IN (' . $db->quote($conditions['user_state']) . ')';
			}
			else
			{
				$sqlConditions[] = 'user.user_state = ' . $db->quote($conditions['user_state']);
			}
		}

		if (isset($conditions['is_admin']))
		{
			$sqlConditions[] = 'user.is_admin = ' . ($conditions['is_admin'] ? 1 : 0);
		}

		if (isset($conditions['is_moderator']))
		{
			$sqlConditions[] = 'user.is_moderator = ' . ($conditions['is_moderator'] ? 1 : 0);
		}

		if (isset($conditions['is_banned']))
		{
			$sqlConditions[] = 'user.is_banned = ' . ($conditions['is_banned'] ? 1 : 0);
		}

		if (isset($conditions['is_staff']))
		{
			$sqlConditions[] = 'user.is_staff = ' . ($conditions['is_staff'] ? 1 : 0);
		}

		if (!empty($conditions['receive_admin_email']))
		{
			$sqlConditions[] = 'user_option.receive_admin_email = 1';
			$this->addFetchOptionJoin($fetchOptions, self::FETCH_USER_OPTION);
		}

		if (!empty($conditions['adminQuickSearch']))
		{
			$quotedString = XenForo_Db::quoteLike($conditions['adminQuickSearch'], 'lr', $db);

			$sqlConditions[] = 'user.username LIKE ' . $quotedString . ' OR user.email LIKE ' . $quotedString;
		}

		// these are conditions, but implemented via fetch options as they need a bunch of joins
		if (!empty($conditions['customFields']) && empty($fetchOptions['customFields']))
		{
			$fetchOptions['customFields'] = $conditions['customFields'];
		}
		if (!empty($conditions['customFieldsExact']) && empty($fetchOptions['customFieldsExact']))
		{
			$fetchOptions['customFieldsExact'] = $conditions['customFieldsExact'];
		}

		return $this->getConditionsForClause($sqlConditions);
	}

	/**
	 * Construct 'ORDER BY' clause
	 *
	 * @param array $fetchOptions (uses 'order' key)
	 * @param string $defaultOrderSql Default order SQL
	 *
	 * @return string
	 */
	public function prepareUserOrderOptions(array &$fetchOptions, $defaultOrderSql = '')
	{
		$choices = array(
			'username' => 'user.username',
			'register_date' => 'user.register_date',
			'message_count' => 'user.message_count',
			'trophy_points' => 'user.trophy_points',
			'like_count' => 'user.like_count',
			'last_activity' => 'user.last_activity'
		);
		return $this->getOrderByClause($choices, $fetchOptions, $defaultOrderSql);
	}

	/**
	 * Returns a full user record based on an input user ID. Equivalent to
	 * calling getUserById including the FETCH_USER_FULL constanct
	 *
	 * @param integer $userId
	 * @param array $fetchOptions User fetch options
	 *
	 * @return array|false
	 */
	public function getFullUserById($userId, array $fetchOptions = array())
	{
		if (!empty($fetchOptions['join']))
		{
			$fetchOptions['join'] |= self::FETCH_USER_FULL;
		}
		else
		{
			$fetchOptions['join'] = self::FETCH_USER_FULL;
		}

		return $this->getUserById($userId, $fetchOptions);
	}

	/**
	 * Gets the visiting user's information based on their user ID.
	 *
	 * @param integer $userId
	 *
	 * @return array
	 */
	public function getVisitingUserById($userId)
	{
		$userinfo = $this->getUserById($userId, array(
			'join' => self::FETCH_USER_FULL | self::FETCH_USER_PERMISSIONS
		));
		if (!$userinfo)
		{
			return false;
		}

		$userinfo['csrf_token_page'] = $userinfo['user_id'] . ',' . XenForo_Application::$time
			. ',' . sha1(XenForo_Application::$time . $userinfo['csrf_token']);

		if ($userinfo['user_state'] != 'valid')
		{
			// user is not valid yet, give them guest permissions
			$userinfo = $this->setPermissionsOnVisitorArray($userinfo);
		}

		return $userinfo;
	}

	/**
	 * Get the visiting user information for a guest.
	 *
	 * @return array
	 */
	public function getVisitingGuestUser()
	{
		$options = XenForo_Application::get('options');

		$userinfo = array(
			// xf_user
			'user_id' => 0,
			'username' => '',
			'email' => '',
			'gender' => '',
			'language_id' => 0,
			'style_id' => 0,
			'timezone' => $options->guestTimeZone,
			'visible' => 1,
			'user_group_id' => self::$defaultGuestGroupId,
			'secondary_group_ids' => '',
			'display_style_group_id' => self::$defaultGuestGroupId,
			'permission_combination_id' => 0,
			'message_count' => 0,
			'conversations_unread' => 0,
			'register_date' => 0,
			'last_activity' => 0,
			'trophy_points' => 0,
			'alerts_unread' => 0,
			'avatar_date' => 0,
			'avatar_width' => 0,
			'avatar_height' => 0,
			'gravatar' => '',
			'user_state' => 'valid',
			'is_moderator' => 0,
			'is_admin' => 0,
			'is_staff' => 0,
			'is_banned' => 0,
			'like_count' => 0,
			'custom_title' => '',
			'warning_points' => '',

			// xf_user_profile
			'dob_day' => 0,
			'dob_month' => 0,
			'dob_year' => 0,
			'status' => '',
			'status_date' => 0,
			'status_profile_post_id' => 0,
			'signature' => '',
			'homepage' => '',
			'location' => '',
			'occupation' => '',
			'following' => '',
			'ignored' => '',
			'csrf_token' => '',
			'avatar_crop_x' => 0,
			'avatar_crop_y' => 0,
			'about' => '',
			'custom_fields' => '',
			'external_auth' => '',

			// xf_user_option
			'show_dob_year' => 0,
			'show_dob_date' => 0,
			'content_show_signature' => $options->guestShowSignatures,
			'receive_admin_email' => 0,
			'email_on_conversation' => 0,
			'is_discouraged' => 0,
			'default_watch_state' => '',
			'alert_optout' => '',
			'enable_rte' => 1,
			'enable_flash_uploader' => 1,

			// xf_user_privacy
			'allow_view_profile' => 'everyone',
			'allow_post_profile' => 'everyone',
			'allow_send_personal_conversation' => 'everyone',
			'allow_view_identities' => 'everyone',
			'allow_receive_news_feed' => 'everyone',

			// other tables/data
			'csrf_token_page' => '',
			'global_permission_cache' => ''
		);

		$userinfo = $this->setPermissionsOnVisitorArray($userinfo);
		return $userinfo;
	}

	/**
	 * Sets the specified permissions (combination and permissions string) on visitor array.
	 * Defaults to setting guest permissions.
	 *
	 * @param array $userinfo Visitor record
	 *
	 * @return array Visitor record with permissions
	 */
	public function setPermissionsOnVisitorArray(array $userinfo, $permissionCombinationId = false)
	{
		if (!$permissionCombinationId)
		{
			$permissionCombinationId = self::$guestPermissionCombinationId;
		}

		$userinfo['permission_combination_id'] = $permissionCombinationId;

		$userinfo['global_permission_cache'] = $this->_getDb()->fetchOne('
			SELECT cache_value
			FROM xf_permission_combination
			WHERE permission_combination_id = ?
		', $permissionCombinationId);

		return $userinfo;
	}

	/**
	 * Sets the permission info from the specified user ID into an array of user info
	 * (likely for a visitor array).
	 *
	 * @param array $userInfo
	 * @param integer $permUserId
	 *
	 * @return array User info with changed permissions
	 */
	public function setPermissionsFromUserId(array $userInfo, $permUserId)
	{
		$permUser = $this->getUserById($permUserId, array(
			'join' => self::FETCH_USER_PERMISSIONS
		));
		if ($permUser)
		{
			$userInfo['permission_combination_id'] = $permUser['permission_combination_id'];
			$userInfo['global_permission_cache'] = $permUser['global_permission_cache'];
		}

		return $userInfo;
	}

	/**
	 * Updates the session activity of a user.
	 *
	 * @param integer $userId
	 * @param string $ip IP of visiting user
	 * @param string $controllerName Last controller class that was invoked
	 * @param string $action Last action that was invoked
	 * @param string $viewState Either "valid" or "error"
	 * @param array $inputParams List of special input params, to include to help get more info on current activity
	 * @param integer|null $viewDate The timestamp of the last page view; defaults to now
	 * @param string $robotKey
	 */
	public function updateSessionActivity($userId, $ip, $controllerName, $action, $viewState, array $inputParams, $viewDate = null, $robotKey = '')
	{
		$userId = intval($userId);
		$ipNum = XenForo_Helper_Ip::getBinaryIp(null, $ip, '');
		$uniqueKey = ($userId ? $userId : $ipNum);

		if ($userId)
		{
			$robotKey = '';
		}

		if (!$viewDate)
		{
			$viewDate = XenForo_Application::$time;
		}

		$logParams = array();
		foreach ($inputParams AS $paramKey => $paramValue)
		{
			if (!strlen($paramKey) || $paramKey[0] == '_' || !is_scalar($paramValue))
			{
				continue;
			}

			$logParams[] = "$paramKey=" . urlencode($paramValue);
		}
		$paramList = implode('&', $logParams);
		$paramList = substr($paramList, 0, 100);

		$controllerName = substr($controllerName, 0, 50);
		$action = substr($action, 0, 50);

		try
		{
			$this->_getDb()->query('
				INSERT INTO xf_session_activity
					(user_id, unique_key, ip, controller_name, controller_action, view_state, params, view_date, robot_key)
				VALUES
					(?, ?, ?, ?, ?, ?, ?, ?, ?)
				ON DUPLICATE KEY UPDATE
					ip = VALUES(ip),
					controller_name = VALUES(controller_name),
					controller_action = VALUES(controller_action),
					view_state = VALUES(view_state),
					params = VALUES(params),
					view_date = VALUES(view_date),
					robot_key = VALUES(robot_key)
			', array($userId, $uniqueKey, $ipNum, $controllerName, $action, $viewState, $paramList, $viewDate, $robotKey));
		}
		catch (Zend_Db_Exception $e) {} // ignore db errors here, not that important
	}

	/**
	 * Deletes the session activity record for the specified user / IP address
	 *
	 * @param integer $userId
	 * @param string $ip
	 */
	public function deleteSessionActivity($userId, $ip)
	{
		$userId = intval($userId);
		$ipNum = XenForo_Helper_Ip::convertIpStringToBinary($ip);
		$uniqueKey = ($userId ? $userId : $ipNum);

		$db = $this->_getDb();
		$db->delete('xf_session_activity', 'user_id = ' . $db->quote($userId) . ' AND unique_key = ' . $db->quote($uniqueKey));
	}

	/**
	 * Gets the latest (valid) user to join.
	 *
	 * @return array|false
	 */
	public function getLatestUser()
	{
		return $this->_getDb()->fetchRow($this->limitQueryResults('
			SELECT *
			FROM xf_user
			WHERE user_state = \'valid\'
				 AND is_banned = 0
			ORDER BY register_date DESC
		', 1));
	}

	/**
	 * Fetch the most recently-registered users
	 *
	 * @param array $criteria
	 * @param array $fetchOptions
	 *
	 * @return array User records
	 */
	public function getLatestUsers(array $criteria, array $fetchOptions = array())
	{
		$fetchOptions['order'] = 'register_date';
		$fetchOptions['direction'] = 'desc';

		return $this->getUsers($criteria, $fetchOptions);
	}

	/**
	 * Fetch the most active users
	 *
	 * @param array $criteria
	 * @param array $fetchOptions
	 *
	 * @return array User records
	 */
	public function getMostActiveUsers(array $criteria, array $fetchOptions = array())
	{
		$fetchOptions['order'] = 'message_count';
		$fetchOptions['direction'] = 'desc';

		return $this->getUsers($criteria, $fetchOptions);
	}

	/**
	 * Gets the count of total users.
	 *
	 * @return integer
	 */
	public function countTotalUsers()
	{
		return $this->_getDb()->fetchOne('
			SELECT COUNT(*)
			FROM xf_user
			WHERE user_state = \'valid\'
				 AND is_banned = 0
		');
	}

	/**
	 * Gets the user authentication record by user ID.
	 *
	 * @param integer $userId
	 *
	 * @return array|false
	 */
	public function getUserAuthenticationRecordByUserId($userId)
	{
		return $this->_getDb()->fetchRow('

			SELECT *
			FROM xf_user_authenticate
			WHERE user_id = ?

		', $userId);
	}

	/**
	 * Returns an auth object based on an input userid
	 *
	 * @param integer Userid
	 *
	 * @return XenForo_Authentication_Abstract|false
	 */
	public function getUserAuthenticationObjectByUserId($userId)
	{
		$authenticate = $this->getUserAuthenticationRecordByUserId($userId);
		if (!$authenticate)
		{
			return false;
		}

		$auth = XenForo_Authentication_Abstract::create($authenticate['scheme_class']);
		if (!$auth)
		{
			return false;
		}

		$auth->setData($authenticate['data']);
		return $auth;
	}

	/**
	 * Logs the given user in (as the visiting user). Exceptions are thrown on errors.
	 *
	 * @param string $nameOrEmail User name or email address
	 * @param string $password
	 * @param string $error Error string (by ref)
	 *
	 * @return integer|false User ID auth'd as; false on failure
	 */
	public function validateAuthentication($nameOrEmail, $password, &$error = '')
	{
		$user = $this->getUserByNameOrEmail($nameOrEmail);
		if (!$user)
		{
			$error = new XenForo_Phrase('requested_user_x_not_found', array('name' => $nameOrEmail));
			return false;
		}

		$authentication = $this->getUserAuthenticationObjectByUserId($user['user_id']);
		if (!$authentication || !$authentication->authenticate($user['user_id'], $password))
		{
			$error = new XenForo_Phrase('incorrect_password');
			return false;
		}

		$upgraded = $authentication->getUpgradedAuthenticationObject();
		if ($upgraded)
		{
			$userDw = XenForo_DataWriter::create('XenForo_DataWriter_User', XenForo_DataWriter::ERROR_SILENT);
			$userDw->setExistingData($user['user_id']);
			$userDw->setOption(XenForo_DataWriter_User::OPTION_LOG_CHANGES, false);
			$userDw->setPassword($password, false, $upgraded);
			$userDw->save();
		}

		return $user['user_id'];
	}

	/**
	 * Logs a user in based on their remember key from a cookie.
	 *
	 * @param integer $userId
	 * @param string $rememberKey
	 * @param array|false|null $auth User's auth record (retrieved if null)
	 *
	 * @return boolean
	 */
	public function loginUserByRememberKeyFromCookie($userId, $rememberKey, $auth = null)
	{
		if ($auth === null)
		{
			$auth = $this->getUserAuthenticationRecordByUserId($userId);
		}

		if (!$auth || $this->prepareRememberKeyForCookie($auth['remember_key']) !== $rememberKey)
		{
			return false;
		}

		return true;
	}

	/**
	 * Logs a user in based on the raw value of the remember cookie.
	 *
	 * @param string $userCookie
	 *
	 * @return false|integer
	 */
	public function loginUserByRememberCookie($userCookie)
	{
		if (!$userCookie)
		{
			return false;
		}

		$userCookieParts = explode(',', $userCookie);
		if (count($userCookieParts) < 2)
		{
			return false;
		}

		$userId = intval($userCookieParts[0]);
		$rememberKey = $userCookieParts[1];
		if (!$userId || !$rememberKey)
		{
			return false;
		}

		$auth = $this->getUserAuthenticationRecordByUserId($userId);
		$loggedIn = $this->loginUserByRememberKeyFromCookie($userId, $rememberKey, $auth);
		if ($loggedIn)
		{
			return $userId;
		}
		else
		{
			return false;
		}
	}

	/**
	 * Sets the user remember cookie for the specified user ID.
	 *
	 * @param integer $userId
	 * @param array|false|null $auth User's auth record (retrieved if null)
	 *
	 * @return boolean
	 */
	public function setUserRememberCookie($userId, $auth = null)
	{
		$value = $this->getUserRememberKeyForCookie($userId, $auth);
		if (!$value)
		{
			return false;
		}

		XenForo_Helper_Cookie::setCookie('user', $value, 30 * 86400, true);

		return true;
	}

	public function getUserRememberKeyForCookie($userId, $auth = null)
	{
		if ($auth === null)
		{
			$auth = $this->getUserAuthenticationRecordByUserId($userId);
		}

		if (!$auth)
		{
			return false;
		}

		return intval($userId) . ',' . $this->prepareRememberKeyForCookie($auth['remember_key']);
	}

	/**
	 * Prepares the remember key for use in a cookie (or for comparison against the cookie).
	 *
	 * @param string $rememberKey Key from DB
	 *
	 * @return string
	 */
	public function prepareRememberKeyForCookie($rememberKey)
	{
		return sha1(XenForo_Application::get('config')->globalSalt . $rememberKey);
	}

	public function getUserEmailConfirmKey(array $user)
	{
		return md5(XenForo_Application::get('config')->globalSalt . $user['user_id'] . $user['email']);
	}

	/**
	 * Prepares a user record for display. Note that this may be called on incomplete guest records.
	 *
	 * @param array $user User info
	 *
	 * @return array Prepared user info
	 */
	public function prepareUser(array $user)
	{
		if (empty($user['user_group_id']))
		{
			$user['display_style_group_id'] = self::$defaultGuestGroupId;
		}

		$user['customFields'] = (!empty($user['custom_fields']) ? @unserialize($user['custom_fields']) : array());
		$user['externalAuth'] = (!empty($user['external_auth']) ? @unserialize($user['external_auth']) : array());

		// "trusted" user check - used to determine if no follow is enabled
		$user['isTrusted'] = (!empty($user['user_id']) && (!empty($user['is_admin']) || !empty($user['is_moderator'])));

		if (XenForo_Visitor::hasInstance())
		{
			$user['isIgnored'] = XenForo_Visitor::getInstance()->isIgnoring($user['user_id']);
		}

		return $user;
	}

	/**
	 * Prepares the data needed for the simple user card-like output.
	 *
	 * @param array $user
	 *
	 * @return array
	 */
	public function prepareUserCard(array $user)
	{
		$user['age'] = $this->_getUserProfileModel()->getUserAge($user);
		$user['customFields'] = (!empty($user['custom_fields']) ? @unserialize($user['custom_fields']) : array());

		return $user;
	}

	/**
	 * Prepares a batch of user cards.
	 *
	 * @param array $users
	 *
	 * @return array
	 */
	public function prepareUserCards(array $users)
	{
		foreach ($users AS &$user)
		{
			$user = $this->prepareUserCard($user);
		}

		return $users;
	}

	/**
	 * Inserts (or updates an existing) user group change set.
	 *
	 * @param integer $userId
	 * @param string $key Unique identifier for change set
	 * @param string|array $addGroups Comma delimited string or array of user groups to add
	 *
	 * @return boolean True on change success
	 */
	public function addUserGroupChange($userId, $key, $addGroups)
	{
		if (is_array($addGroups))
		{
			$addGroups = implode(',', $addGroups);
		}

		if (!$addGroups)
		{
			return true;
		}

		$oldGroups = $this->getUserGroupChangesForUser($userId);

		$newGroups = $oldGroups;

		if (isset($newGroups[$key]) && !$addGroups)
		{
			// already exists and we're removing the groups, so we can just remove the record
			return $this->removeUserGroupChange($userId, $key);
		}

		$newGroups[$key] = $addGroups;

		$db = $this->_getDb();
		XenForo_Db::beginTransaction($db);

		$success = $this->_applyUserGroupChanges($userId, $oldGroups, $newGroups);
		if ($success)
		{
			$db->query('
				INSERT INTO xf_user_group_change
					(user_id, change_key, group_ids)
				VALUES
					(?, ?, ?)
				ON DUPLICATE KEY UPDATE
					group_ids = VALUES(group_ids)
			', array($userId, $key, $addGroups));

			XenForo_Db::commit($db);
		}
		else
		{
			XenForo_Db::rollback($db);
		}


		return $success;
	}

	/**
	 * Removes the specified user group change set.
	 *
	 * @param integer $userId
	 * @param string $key Change set key
	 *
	 * @return boolean True on success
	 */
	public function removeUserGroupChange($userId, $key)
	{
		$oldGroups = $this->getUserGroupChangesForUser($userId);
		if (!isset($oldGroups[$key]))
		{
			// already removed?
			return true;
		}

		$newGroups = $oldGroups;
		unset($newGroups[$key]);

		$db = $this->_getDb();
		XenForo_Db::beginTransaction($db);

		$success = $this->_applyUserGroupChanges($userId, $oldGroups, $newGroups);
		if ($success)
		{
			$db->delete('xf_user_group_change',
				'user_id = ' . $db->quote($userId) . ' AND change_key = ' . $db->quote($key)
			);

			XenForo_Db::commit($db);
		}
		else
		{
			XenForo_Db::rollback($db);
		}

		return $success;
	}

	public function removeUserGroupChangeLogByKey($key)
	{
		$db = $this->_getDb();
		$db->delete('xf_user_group_change', 'change_key = ' . $db->quote($key));
	}

	/**
	 * Gets the user group change sets for the specified user.
	 *
	 * @param integer $userId
	 *
	 * @return array [change key] => comma list of group IDs
	 */
	public function getUserGroupChangesForUser($userId)
	{
		return $this->_getDb()->fetchPairs('
			SELECT change_key, group_ids
			FROM xf_user_group_change
			WHERE user_id = ?
		', $userId);
	}

	/**
	 * Applies a set of user group changes.
	 *
	 * @param integer $userId
	 * @param array $oldGroupStrings Array of comma-delimited strings of existing (accounted for) user group change sets
	 * @param array $newGroupStrings Array of comma-delimited strings for new list of user group change sets
	 *
	 * @return boolean
	 */
	protected function _applyUserGroupChanges($userId, array $oldGroupStrings, array $newGroupStrings)
	{
		$oldGroups = array();
		foreach ($oldGroupStrings AS $string)
		{
			$oldGroups = array_merge($oldGroups, explode(',', $string));
		}
		$oldGroups = array_unique($oldGroups);

		$newGroups = array();
		foreach ($newGroupStrings AS $string)
		{
			$newGroups = array_merge($newGroups, explode(',', $string));
		}
		$newGroups = array_unique($newGroups);

		$addGroups = array_diff($newGroups, $oldGroups);
		$removeGroups = array_diff($oldGroups, $newGroups);

		if (!$addGroups && !$removeGroups)
		{
			return true;
		}

		$dw = XenForo_DataWriter::create('XenForo_DataWriter_User', XenForo_DataWriter::ERROR_SILENT);
		if (!$dw->setExistingData($userId))
		{
			return false;
		}

		$secondaryGroups = explode(',', $dw->get('secondary_group_ids'));
		if ($removeGroups)
		{
			foreach ($secondaryGroups AS $key => $secondaryGroup)
			{
				if (in_array($secondaryGroup, $removeGroups))
				{
					unset($secondaryGroups[$key]);
				}
			}
		}
		if ($addGroups)
		{
			$secondaryGroups = array_merge($secondaryGroups, $addGroups);
		}

		$dw->setSecondaryGroups($secondaryGroups);
		return $dw->save();
	}

	/**
	 * Determines if a user is a member of a particular user group
	 *
	 * @param array $user
	 * @param integer|array $userGroupId either a single user group ID or an array thereof
	 * @param boolean Also check secondary groups
	 *
	 * @return boolean
	 */
	public function isMemberOfUserGroup(array $user, $userGroupId, $includeSecondaryGroups = true)
	{
		if (is_array($userGroupId))
		{
			if (in_array($user['user_group_id'], $userGroupId))
			{
				return true;
			}

			if ($includeSecondaryGroups && array_intersect($userGroupId, explode(',', $user['secondary_group_ids'])))
			{
				return true;
			}
		}
		else
		{
			if ($user['user_group_id'] == $userGroupId)
			{
				return true;
			}

			if ($includeSecondaryGroups && strpos(",{$user['secondary_group_ids']},", ",{$userGroupId},") !== false)
			{
				return true;
			}
		}

		return false;
	}

	public function isUserSuperAdmin(array $user)
	{
		$superAdmins = preg_split(
			'#\s*,\s*#', XenForo_Application::get('config')->superAdmins,
			-1, PREG_SPLIT_NO_EMPTY
		);
		return ($user['is_admin'] && in_array($user['user_id'], $superAdmins));
	}

	/**
	 * Creates a new follower record for $userId following $followUserId(s)
	 *
	 * @param array Users being followed
	 * @param boolean Check for and prevent duplicate followers
	 * @param array $user User doing the following
	 *
	 * @return string Comma-separated list of all users now being followed by $userId
	 */
	public function follow(array $followUsers, $dupeCheck = true, array $user = null)
	{
		if ($user === null)
		{
			$user = XenForo_Visitor::getInstance();
		}

		// if we have only a single user, build the multi-user array structure
		if (isset($followUsers['user_id']))
		{
			$followUsers = array($followUsers['user_id'] => $followUsers);
		}

		if ($dupeCheck)
		{
			$followUsers = $this->removeDuplicateFollowUserIds($user['user_id'], $followUsers);
		}

		$db = $this->_getDb();
		$errors = false;

		XenForo_Db::beginTransaction($db);

		foreach ($followUsers AS $followUser)
		{
			if ($user['user_id'] == $followUser['user_id'])
			{
				continue;
			}

			$writer = XenForo_DataWriter::create('XenForo_DataWriter_Follower');
			$writer->setOption(XenForo_DataWriter_Follower::OPTION_POST_WRITE_UPDATE_USER_FOLLOWING, false);
			$writer->set('user_id', $user['user_id']);
			$writer->set('follow_user_id', $followUser['user_id']);
			$success = $writer->save();

			if ($success && XenForo_Model_Alert::userReceivesAlert($followUser, 'user', 'following'))
			{
				XenForo_Model_Alert::alert(
					$followUser['user_id'],
					$user['user_id'], $user['username'],
					'user', $followUser['user_id'],
					'following'
				);
			}
		}

		$return = $this->updateFollowingDenormalizedValue($user['user_id']);

		XenForo_Db::commit($db);

		return $return;
	}

	/**
	 * Deletes an existing follower record for $userId following $followUserId
	 *
	 * @param integer $followUserId User being followed
	 * @param integer $userId User doing the following
	 *
	 * @return string Comma-separated list of all users now being followed by $userId
	 */
	public function unfollow($followUserId, $userId = null)
	{
		if ($userId === null)
		{
			$userId = XenForo_Visitor::getUserId();
		}

		$db = $this->_getDb();

		XenForo_Db::beginTransaction($db);

		$writer = XenForo_DataWriter::create('XenForo_DataWriter_Follower', XenForo_DataWriter::ERROR_SILENT);
		$writer->setOption(XenForo_DataWriter_Follower::OPTION_POST_WRITE_UPDATE_USER_FOLLOWING, false);
		$writer->setExistingData(array($userId, $followUserId));
		$writer->delete();

		$value = $this->updateFollowingDenormalizedValue($userId);

		// delete alerts
		$this->getModelFromCache('XenForo_Model_Alert')->deleteAlerts('user', $followUserId, $userId, 'follow');

		XenForo_Db::commit($db);

		return $value;
	}

	/**
	 * Compares an array of user IDs to be followed with the existing value and removes any duplicates
	 * to prevent duplicate key errors on insertion
	 *
	 * @param integer $userId
	 * @param array $newUsers (full user arrays)
	 * @param string $existingUserIds '3,6,42,....'
	 *
	 * @return array
	 */
	public function removeDuplicateFollowUserIds($userId, array $newUsers, $existingUserIds = null)
	{
		if ($existingUserIds === null)
		{
			$existingUserIds = $this->getFollowingDenormalizedValue($userId);
		}

		$existingUserIds = explode(',', $existingUserIds);

		foreach ($newUsers AS $i => $newUser)
		{
			if (in_array($newUser['user_id'], $existingUserIds))
			{
				unset($newUsers[$i]);
			}
		}

		return $newUsers;
	}

	/**
	 * Fetches a single user-following-user record.
	 *
	 * @param integer|array $userId - the user doing the following
	 * @param integer|array $followUserId - the user being followed
	 *
	 * @return array
	 */
	public function getFollowRecord($userId, $followUserId)
	{
		return $this->_getDb()->fetchRow('

			SELECT *
			FROM xf_user_follow
			WHERE user_id = ?
			AND follow_user_id = ?

		', array(
			$this->getUserIdFromUser($userId),
			$this->getUserIdFromUser($followUserId)
		));
	}

	/**
	 * Gets an array of all users being followed by the specified user
	 *
	 * @param integer|array $userId|$user
	 * @param integer $maxResults (0 = all)
	 * @param string $orderBy
	 *
	 * @return array
	 */
	public function getFollowedUserProfiles($userId, $maxResults = 0, $orderBy = 'user.username')
	{
		$sql = '
			SELECT
				user.*,
				user_profile.*,
				user_option.*
			FROM xf_user_follow AS user_follow
			INNER JOIN xf_user AS user ON
				(user.user_id = user_follow.follow_user_id AND user.is_banned = 0)
			INNER JOIN xf_user_profile AS user_profile ON
				(user_profile.user_id = user.user_id)
			INNER JOIN xf_user_option AS user_option ON
				(user_option.user_id = user.user_id)
			WHERE user_follow.user_id = ?
			ORDER BY ' . $orderBy . '
		';

		if ($maxResults)
		{
			$sql = $this->limitQueryResults($sql, $maxResults);
		}

		return $this->fetchAllKeyed($sql, 'user_id', $this->getUserIdFromUser($userId));
	}

	/**
	 * Gets users followed by the user ID.
	 *
	 * @param integer $userId
	 * @param array $fetchOptions
	 *
	 * @return array
	 */
	public function getFollowedUsers($userId, array $fetchOptions = array())
	{
		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT user.*,
					user_profile.*,
					user_option.*
				FROM xf_user_follow AS user_follow
				INNER JOIN xf_user AS user ON
					(user.user_id = user_follow.follow_user_id AND user.is_banned = 0)
				INNER JOIN xf_user_profile AS user_profile ON
					(user_profile.user_id = user.user_id)
				INNER JOIN xf_user_option AS user_option ON
					(user_option.user_id = user.user_id)
				WHERE user_follow.user_id = ?
				' . $orderClause . '
			', $limitOptions['limit'], $limitOptions['offset']
		), 'user_id', $userId);
	}

	/**
	 * Generates the denormalized, comma-separated version of a user's following
	 *
	 * @param $userId
	 *
	 * @return string
	 */
	public function getFollowingDenormalizedValue($userId)
	{
		return implode(',', $this->_getDb()->fetchCol('

			SELECT follow_user_id
			FROM xf_user_follow AS user_follow
			INNER JOIN xf_user AS user ON
				(user.user_id = user_follow.follow_user_id)
			WHERE user_follow.user_id = ?
			ORDER BY user.username

		', $this->getUserIdFromUser($userId)));
	}

	/**
	 * Returns whether or not the specified user is being followed by the follower
	 *
	 * @param integer $userId User being followed
	 * @param array $follower User doing the following
	 *
	 * @return boolean
	 */
	public function isFollowing($userId, array $follower = null)
	{
		if ($follower === null)
		{
			$follower = XenForo_Visitor::getInstance();
		}

		if (!$follower['user_id'] || $follower['user_id'] == $userId)
		{
			return false;
		}

		return (strpos(",{$follower['following']},", ",{$userId},") !== false);
	}

	/**
	 * Updates the denormalized, comma-separated version of a user's following.
	 * Will query for the value if it is not provided
	 *
	 * @param integer|array $userId|$user
	 * @param string Denormalized following value
	 *
	 * @return string
	 */
	public function updateFollowingDenormalizedValue($userId, $following = false)
	{
		$userId = $this->getUserIdFromUser($userId);

		if ($following === false)
		{
			$following = $this->getFollowingDenormalizedValue($userId);
		}

		$this->update($userId, 'following', $following);

		return $following;
	}

	/**
	 * Gets the user information for all users following the specified user.
	 *
	 * @param integer $userId
	 * @param integer $maxResults (0 = all)
	 * @param string $orderBy
	 *
	 * @return array Format: [user id] => following user info
	 */
	public function getUsersFollowingUserId($userId, $maxResults = 0, $orderBy = 'user.username')
	{
		$sql = '
			SELECT user.*,
				user_profile.*,
				user_option.*
			FROM xf_user_follow AS user_follow
			INNER JOIN xf_user AS user ON
				(user.user_id = user_follow.user_id AND user.is_banned = 0)
			INNER JOIN xf_user_profile AS user_profile ON
				(user_profile.user_id = user.user_id)
			INNER JOIN xf_user_option AS user_option ON
				(user_option.user_id = user.user_id)
			WHERE user_follow.follow_user_id = ?
			ORDER BY ' . $orderBy . '
		';

		if ($maxResults)
		{
			$sql = $this->limitQueryResults($sql, $maxResults);
		}

		return $this->fetchAllKeyed($sql, 'user_id', $userId);
	}

	/**
	 * Gets users following the specified user ID.
	 *
	 * @param integer $userId
	 * @param array $fetchOptions
	 *
	 * @return array
	 */
	public function getUsersFollowing($userId, array $fetchOptions = array())
	{
		$orderClause = $this->prepareUserOrderOptions($fetchOptions, 'user.username');
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT user.*,
					user_profile.*,
					user_option.*
				FROM xf_user_follow AS user_follow
				INNER JOIN xf_user AS user ON
					(user.user_id = user_follow.user_id AND user.is_banned = 0)
				INNER JOIN xf_user_profile AS user_profile ON
					(user_profile.user_id = user.user_id)
				INNER JOIN xf_user_option AS user_option ON
					(user_option.user_id = user.user_id)
				WHERE user_follow.follow_user_id = ?
				' . $orderClause . '
			', $limitOptions['limit'], $limitOptions['offset']
		), 'user_id', $userId);
	}

	/**
	 * Gets the count of users following the specified user.
	 *
	 * @param integer $userId
	 *
	 * @return array Format: [user id] => following user info
	 */
	public function countUsersFollowingUserId($userId)
	{
		return $this->_getDb()->fetchOne('
			SELECT COUNT(*)
			FROM xf_user_follow
			WHERE follow_user_id = ?
		', $userId);
	}

	/**
	 * Rebuilds the custom field cache for a specific user, using the canonical data.
	 *
	 * @param integer $userId
	 */
	public function rebuildCustomFieldCache($userId)
	{
		$db = $this->_getDb();

		$cache = $this->_getFieldModel()->getUserFieldValues($userId);

		$db->update('xf_user_profile',
			array('custom_fields' => serialize($cache)),
			'user_id = '. $db->quote($userId)
		);
	}

	/**
	 * Returns an array containing the user ids found from the complete result given the range specified,
	 * along with the total number of users found.
	 *
	 * @param integer Find users with user_id greater than...
	 * @param integer Maximum users to return at once
	 *
	 * @return array
	 */
	public function getUserIdsInRange($start, $limit)
	{
		$db = $this->_getDb();

		return $db->fetchCol($db->limit('
			SELECT user_id
			FROM xf_user
			WHERE user_id > ?
			ORDER BY user_id
		', $limit), $start);
	}

	/**
	 * Returns the number of unread alerts belonging to a user - following fresh recalculation
	 *
	 * @param integer $userId
	 *
	 * @return integer
	 */
	public function getUnreadAlertsCount($userId)
	{
		return $this->_getDb()->fetchOne('
			SELECT COUNT(*) AS total
			FROM xf_user_alert
			WHERE alerted_user_id = ?
				AND view_date = 0
		', $userId);
	}

	/**
	 * Determines if the user has permission to bypass users' privacy preferences, including online status and activity feed
	 *
	 * @param string $errorPhraseKey
	 * @param array $viewingUser
	 *
	 * @return boolean
	 */
	public function canBypassUserPrivacy(&$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (XenForo_Permission::hasPermission($viewingUser['permissions'], 'general', 'bypassUserPrivacy'))
		{
			return true;
		}

		return false;
	}

	/**
	 * Determines if permissions are sufficient to view on the specified
	 * user's online status.
	 *
	 * @param array $user User being viewed
	 * @param string $errorPhraseKey Returned by ref. Phrase key of more specific error
	 * @param array|null $viewingUser Viewing user ref
	 *
	 * @return boolean
	 */
	public function canViewUserOnlineStatus(array $user, &$errorPhraseKey = '', array $viewingUser = null)
	{
		if (!$user['user_id'] || !$user['last_activity'])
		{
			return false;
		}
		else if ($user['visible'])
		{
			return true;
		}

		$this->standardizeViewingUserReference($viewingUser);

		if ($user['user_id'] == $viewingUser['user_id'])
		{
			// can always view own
			return true;
		}

		return $this->canBypassUserPrivacy($errorPhraseKey, $viewingUser);
	}

	/**
	 * Determines if permissions are sufficient to warn the given user.
	 *
	 * @param array $user User being viewed
	 * @param string $errorPhraseKey Returned by ref. Phrase key of more specific error
	 * @param array|null $viewingUser Viewing user ref
	 *
	 * @return boolean
	 */
	public function canWarnUser(array $user, &$errorPhraseKey = '', array $viewingUser = null)
	{
		if (!empty($user['is_admin']) || !empty($user['is_moderator']) || empty($user['user_id']))
		{
			return false;
		}

		$this->standardizeViewingUserReference($viewingUser);

		return ($viewingUser['user_id'] && XenForo_Permission::hasPermission($viewingUser['permissions'], 'general', 'warn'));
	}

	/**
	 * Checks that the viewing user may report the specified user
	 *
	 * @param array $user User being viewed
	 * @param string $errorPhraseKey Returned by ref. Phrase key of more specific error
	 * @param array|null $viewingUser Viewing user ref
	 *
	 * @return boolean
	 */
	public function canReportUser(array $user, &$errorPhraseKey = '', array $viewingUser = null)
	{
		if ($user['is_staff'])
		{
			return false;
		}

		return $this->canReportContent($errorPhraseKey, $viewingUser);
	}

	/**
	 * Determines if the viewing user can edit a user's basic details
	 * (moderator-level permission for editing)
	 *
	 * @param array $user
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canEditUser(array $user, &$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (!$viewingUser['user_id'])
		{
			return false;
		}

		$viewerIsSuperAdmin = $this->isUserSuperAdmin($viewingUser);
		if ($viewerIsSuperAdmin)
		{
			return true;
		}

		if ($user['is_admin'] && $this->isUserSuperAdmin($user) && !$viewerIsSuperAdmin)
		{
			return false;
		}

		if ($viewingUser['is_admin'])
		{
			$adminPermissions = XenForo_Model::create('XenForo_Model_Admin')->getAdminPermissionCacheForUser(
				$viewingUser['user_id']
			);
			if ($adminPermissions && !empty($adminPermissions['user']))
			{
				return true;
			}
		}

		if ($user['is_admin'] || $user['is_moderator'] || $user['is_staff'])
		{
			// moderators can't edit admins/mods/staff
			return false;
		}

		return XenForo_Permission::hasPermission($viewingUser['permissions'], 'general', 'editBasicProfile');
	}

	/**
	 * Determines if the viewing user can start a conversation with the given user.
	 * Does not check standard conversation permissions.
	 *
	 * @param array $user
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canStartConversationWithUser(array $user, &$errorPhraseKey = '', array $viewingUser = null)
	{
		if (!$user['user_id'])
		{
			return false;
		}

		return $this->getModelFromCache('XenForo_Model_Conversation')->canStartConversationWithUser(
			$user, $errorPhraseKey, $viewingUser
		);
	}

	/**
	 * Determines if the viewing user can view IPs logged with posts, profile posts etc.
	 *
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canViewIps(&$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		return ($viewingUser['user_id'] && XenForo_Permission::hasPermission($viewingUser['permissions'], 'general', 'viewIps'));
	}

	/**
	 * Determines if a user can report the specified content
	 *
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canReportContent(&$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (!$viewingUser['user_id'] || !XenForo_Permission::hasPermission($viewingUser['permissions'], 'general', 'report'))
		{
			$errorPhraseKey = 'you_may_not_report_this_content';
			return false;
		}

		return true;
	}

	/**
	 * Determines if a user can view the warnings
	 *
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canViewWarnings(&$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (!$viewingUser['user_id'] || !XenForo_Permission::hasPermission($viewingUser['permissions'], 'general', 'viewWarning'))
		{
			return false;
		}

		return true;
	}

	/**
	 * Determines if the viewing user can start conversations in general.
	 *
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canStartConversations(&$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (!$viewingUser['user_id'])
		{
			return false;
		}

		if ($viewingUser['user_state'] != 'valid')
		{
			return false;
		}

		if (XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'start'))
		{
			$maxRecipients = XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'maxRecipients');
			return ($maxRecipients == -1 || $maxRecipients > 0);
		}

		return false;
	}

	/**
	 * Determines if the viewing user passes the specified privacy check.
	 * This must include the following status for the viewing user.
	 *
	 * @param string $privacyRequirement The required privacy: everyone, none, members, followed
	 * @param array $user User info, including following status for viewing user
	 * @param array|null $viewingUser Viewing user ref
	 * @return unknown_type
	 */
	public function passesPrivacyCheck($privacyRequirement, array $user, array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (!isset($user['following_' . $viewingUser['user_id']]) && !isset($user['following']))
		{
			throw new XenForo_Exception('Missing following state for user ID ' . $viewingUser['user_id'] . ' in user ' . $user['user_id']);
		}

		if ($user['user_id'] == $viewingUser['user_id'])
		{
			return true;
		}

		if ($this->canBypassUserPrivacy($null, $viewingUser))
		{
			return true;
		}

		switch ($privacyRequirement)
		{
			case 'everyone': return true;
			case 'none':     return false;
			case 'members':  return ($viewingUser['user_id'] > 0);

			case 'followed':
				if (isset($user['following_' . $viewingUser['user_id']]))
				{
					return ($user['following_' . $viewingUser['user_id']] > 0);
				}
				else if (!empty($user['following']))
				{
					return in_array($viewingUser['user_id'], explode(',', $user['following']));
				}
				else
				{
					return false;
				}


			default:
				return true;
		}
	}

	/**
	 * Fetches the logged registration IP addresses for the specified user, if available.
	 *
	 * @param integer $userId
	 *
	 * @return array [ register: string, account-confirmation: string ]
	 */
	public function getRegistrationIps($userId)
	{
		$ips = $this->_getDb()->fetchPairs('
			SELECT action, ip
			FROM xf_ip
			WHERE user_id = ?
			AND content_type = \'user\'
			AND action IN(\'register\', \'account-confirmation\')
		', $userId);

		return array_map(array('XenForo_Helper_Ip', 'convertIpBinaryToString'), $ips);
	}

	/**
	 * Determines whether or not the specified user may have the spam cleaner applied against them.
	 *
	 * @param array $user
	 * @param string|array Error phrase key - may become an array if the phrase requires parameters
	 *
	 * @return boolean
	 */
	public function couldBeSpammer(array $user, &$errorKey = '')
	{
		// self
		if ($user['user_id'] == XenForo_Visitor::getUserId())
		{
			$errorKey = 'sorry_dave';
			return false;
		}

		// staff
		if ($user['is_admin'] || $user['is_moderator'])
		{
			$errorKey = 'spam_cleaner_no_admins_or_mods';
			return false;
		}

		$criteria = XenForo_Application::get('options')->spamUserCriteria;

		if ($criteria['message_count'] && $user['message_count'] > $criteria['message_count'])
		{
			$errorKey = array('spam_cleaner_too_many_messages', 'message_count' => $criteria['message_count']);
			return false;
		}

		if ($criteria['register_date'] && $user['register_date'] < (XenForo_Application::$time - $criteria['register_date'] * 86400))
		{
			$errorKey = array('spam_cleaner_registered_too_long', 'register_days' => $criteria['register_date']);
			return false;
		}

		if ($criteria['like_count'] && $user['like_count'] > $criteria['like_count'])
		{
			$errorKey = array('spam_cleaner_too_many_likes', 'like_count' => $criteria['like_count']);
			return false;
		}

		return true;
	}

	/**
	 * Bans a user or updates an existing ban.
	 *
	 * @param integer $userId ID of user to ban
	 * @param integer $endDate Date at which ban will be lifted. Use XenForo_Model_User::PERMANENT_BAN for a permanent ban.
	 * @param $reason
	 * @param $update
	 * @param $errorKey
	 * @param $viewingUser
	 * @param $triggered
	 *
	 * @return boolean
	 */
	public function ban($userId, $endDate, $reason, $update = false, &$errorKey = null, array $viewingUser = null, $triggered = false)
	{
		if ($endDate < XenForo_Application::$time && $endDate !== self::PERMANENT_BAN)
		{
			$errorKey = 'please_enter_a_date_in_the_future';
			return false;
		}

		$this->standardizeViewingUserReference($viewingUser);

		$dw = XenForo_DataWriter::create('XenForo_DataWriter_UserBan');
		if ($update)
		{
			$dw->setExistingData($userId);
		}
		else
		{
			$dw->set('user_id', $userId);
			$dw->set('ban_user_id', $viewingUser['user_id']);
		}

		$dw->set('end_date', $endDate);
		$dw->set('user_reason', $reason);
		if ($triggered || $dw->isChanged('end_date'))
		{
			$dw->set('triggered', $triggered ? 1 : 0);
		}
		$dw->preSave();

		if ($dw->hasErrors())
		{
			$errors = $dw->getErrors();
			$errorKey = reset($errors);
			return false;
		}

		$dw->save();
		return true;
	}

	/**
	 * Lifts the ban on the specified user
	 *
	 * @param integer $userId
	 *
	 * @return boolean
	 */
	public function liftBan($userId)
	{
		$dw = XenForo_DataWriter::create('XenForo_DataWriter_UserBan', XenForo_DataWriter::ERROR_SILENT);
		$dw->setExistingData($userId);
		return $dw->delete();
	}

	/**
	 * Gets the date of the earliest user registration
	 *
	 * @return integer
	 */
	public function getEarliestRegistrationDate()
	{
		return (int)$this->_getDb()->fetchOne('SELECT MIN(register_date) FROM xf_user');
	}

	/**
	 * Returns true if the specified user ID or user name is in the ignored (cache) of the given user.
	 *
	 * @param array $user
	 * @param integer|string User ID or user name
	 *
	 * @return array|boolean
	 */
	public function isUserIgnored(array $user, $ignoredUser)
	{
		if (empty($user['ignored']) || !$ignoredUser)
		{
			return false;
		}

		$userId = $user['user_id'];

		if (!isset($this->_ignoreCache[$userId]))
		{
			$this->_ignoreCache[$userId] = unserialize($user['ignored']);
		}

		if (is_int($ignoredUser) && isset($this->_ignoreCache[$userId][$ignoredUser]))
		{
			return array($ignoredUser, $this->_ignoreCache[$userId][$ignoredUser]);
		}

		if (is_string($ignoredUser))
		{
			$ignoredUserId = array_search($ignoredUser, $this->_ignoreCache[$userId]);

			if ($ignoredUserId !== false)
			{
				return array($ignoredUserId, $this->_ignoreCache[$userId][$ignoredUserId]);
			}
		}

		return false;
	}

	/**
	 * List of user IDs/names to be changed when appropriate (name changes,
	 * user deletes, user merges).
	 *
	 * Key is the table name. Value is an array of arrays. Inner array can have 1-3 elements:
	 * 0. user_id column name
	 * 1. username column name (unspecified/false if there isn't one)
	 * 2. boolean to control whether this is updated when deleting a user (unspecified defaults to true).
	 *
	 * Delete updates should only be turned off when the user_id record is absolutely needed or the data has already been removed.
	 *
	 * @var array
	 */
	public static $userContentChanges = array(
		'xf_admin_log' => array(array('user_id', false, false)),
		'xf_attachment_data' => array(array('user_id')),
		'xf_conversation_master' => array(array('user_id', 'username'), array('last_message_user_id', 'last_message_username')),
		'xf_conversation_message' => array(array('user_id', 'username')),
		'xf_conversation_recipient' => array(array('user_id', false, false)),
		'xf_conversation_user' => array(array('owner_user_id', false, false), array('last_message_user_id', 'last_message_username')),
		'xf_deletion_log' => array(array('delete_user_id', 'delete_username')),
		'xf_edit_history' => array(array('edit_user_id')),
		'xf_forum' => array(array('last_post_user_id', 'last_post_username')),
		'xf_ip' => array(array('user_id')),
		'xf_liked_content' => array(array('like_user_id'), array('content_user_id')),
		'xf_news_feed' => array(array('user_id', 'username', false)),
		'xf_poll_vote' => array(array('user_id', false, false)),
		'xf_post' => array(array('user_id', 'username')),
		'xf_profile_post' => array(array('profile_user_id', false, false), array('user_id', 'username')),
		'xf_profile_post_comment' => array(array('user_id', 'username')),
		'xf_report' => array(array('content_user_id'), array('last_modified_user_id', 'last_modified_username')),
		'xf_report_comment' => array(array('user_id', 'username')),
		'xf_spam_cleaner_log' => array(array('user_id', 'username'), array('applying_user_id', 'applying_username')),
		'xf_thread' => array(array('user_id', 'username'), array('last_post_user_id', 'last_post_username')),
		'xf_thread_user_post' => array(array('user_id')),
		'xf_thread_watch' => array(array('user_id')),
		'xf_user_alert' => array(array('user_id', 'username')),
		'xf_user_follow' => array(array('user_id', false, false), array('follow_user_id', false, false)),
		'xf_user_ignored' => array(array('user_id', false, false), array('ignored_user_id', false, false)),
		'xf_user_trophy' => array(array('user_id')),
		'xf_user_upgrade_active' => array(array('user_id', false, false)),
		'xf_user_upgrade_expired' => array(array('user_id', false, false)),
		'xf_warning' => array(array('user_id'), array('warning_user_id')),
	);

	/**
	 * Updates existing content with a new username, or updated user ID
	 *
	 * @param integer $existingUserId
	 * @param string $newUserName
	 * @param string $oldUserName
	 * @param integer $newUserId (set if the user ownership has changed)
	 */
	public function changeContentUser($existingUserId, $newUserName = null, $oldUserName = null, $newUserId = null)
	{
		if ($existingUserId === $newUserId)
		{
			$newUserId = null;
		}

		if ($newUserName === null && $newUserId === null)
		{
			return;
		}

		$db = $this->_getDb();

		XenForo_Db::beginTransaction($db);

		foreach (self::$userContentChanges AS $table => $changes)
		{
			foreach ($changes AS $change)
			{
				$userIdCol = $change[0];
				$userNameCol = isset($change[1]) ? $change[1] : false;
				$updateEmptyUserId = isset($change[2]) ? $change[2] : true;

				$update = array();
				if ($newUserId !== null && ($updateEmptyUserId || $newUserId))
				{
					$update[] = "$userIdCol = " . $db->quote($newUserId);
				}
				if ($newUserName !== null && $userNameCol)
				{
					$update[] = "$userNameCol = " . $db->quote($newUserName);
				}
				if ($update)
				{
					$db->query('
						UPDATE IGNORE ' . $table . ' SET
							' . implode(',', $update) . '
						WHERE ' . $userIdCol . ' = ?
					', $existingUserId);
				}
			}
		}

		if ($newUserName || $newUserId !== null)
		{
			$updatedUserId = ($newUserId !== null) ? $newUserId : $existingUserId;
			$updatedUsername = $newUserName ? $newUserName : $oldUserName;

			$likeHandlers = $this->getModelFromCache('XenForo_Model_Like')->getLikeHandlers();
			foreach ($likeHandlers AS $contentType => $likeHandler)
			{
				$likeHandler->batchUpdateContentUser($existingUserId, $updatedUserId, $oldUserName, $updatedUsername);
			}
		}

		XenForo_Db::commit($db);
	}

	/**
	 * Rebuilds the user moderation queue cache.
	 *
	 * @return array Cache, [total, lastModifiedDate]
	 */
	public function rebuildUserModerationQueueCache()
	{
		$cache = array(
			'total' => $this->countUsers(array('user_state' => 'moderated')),
			'lastModifiedDate' => XenForo_Application::$time
		);

		$this->_getDataRegistryModel()->set('userModerationCounts', $cache);

		return $cache;
	}

	public function mergeUsers(array $target, array $source)
	{
		if ($target['user_id'] == $source['user_id'])
		{
			return true;
		}

		$dw = XenForo_DataWriter::create('XenForo_DataWriter_User', XenForo_DataWriter::ERROR_SILENT);
		if ($dw->setExistingData($target))
		{
			XenForo_Db::beginTransaction();

			// these are going to end up being the same person
			// and you can't like your own content so delete them
			$this->_getDb()->query("
				DELETE FROM xf_liked_content
				WHERE like_user_id = ?
					AND content_user_id = ?
			", array($source['user_id'], $target['user_id']));

			$dw->set('message_count', $dw->get('message_count') + $source['message_count']);
			$dw->set('like_count', $dw->get('like_count') + $source['like_count']); // this isn't 100% perfect
			$dw->set('conversations_unread', $dw->get('conversations_unread') + $source['conversations_unread']);
			$dw->set('alerts_unread', $dw->get('alerts_unread') + $source['alerts_unread']);
			$dw->set('warning_points', $dw->get('warning_points') + $source['warning_points']);
			$dw->set('register_date', min($dw->get('register_date'), $source['register_date']));
			$dw->set('last_activity', max($dw->get('last_activity'), $source['last_activity']));
			$dw->save();

			$this->changeContentUser($source['user_id'], $target['username'], $source['username'], $target['user_id']);

			$deleteDw = XenForo_DataWriter::create('XenForo_DataWriter_User', XenForo_DataWriter::ERROR_SILENT);
			$deleteDw->setExistingData($source);
			$deleteDw->delete();

			$this->getModelFromCache('XenForo_Model_Trophy')->updateTrophyPointsForUser($target['user_id']);

			// this will survive the user delete - anything left over is where both users
			// were in the same conversation so we can remove the old records
			$this->_getDb()->query("
				DELETE FROM xf_conversation_recipient
				WHERE user_id = ?
			", $source['user_id']);

			XenForo_Db::commit();

			return true;
		}
		else
		{
			return false;
		}
	}

	/**
	 * @see XenForo_Model_UserChangeLog::logChanges
	 */
	public function logChanges($userId, array $changedFields, $editUserId = null)
	{
		return $this->getModelFromCache('XenForo_Model_UserChangeLog')->logChanges($userId, $changedFields, $editUserId);
	}

	/**
	 * @return XenForo_Model_UserProfile
	 */
	protected function _getUserProfileModel()
	{
		return $this->getModelFromCache('XenForo_Model_UserProfile');
	}

	/**
	 * @return XenForo_Model_UserField
	 */
	protected function _getFieldModel()
	{
		return $this->getModelFromCache('XenForo_Model_UserField');
	}

	/**
	 * @return XenForo_Model_Ip
	 */
	protected function _getIpModel()
	{
		return $this->getModelFromCache('XenForo_Model_Ip');
	}
}