<?php

/**
 * Model for conversations.
 *
 * @package XenForo_Conversation
 */
class XenForo_Model_Conversation extends XenForo_Model
{
	const FETCH_LAST_MESSAGE_AVATAR = 0x01;
	const FETCH_FIRST_MESSAGE = 0x02;
	const FETCH_RECEIVED_BY = 0x04;

	/**
	 * Gets the specified conversation master record.
	 *
	 * @param integer $conversationId
	 *
	 * @return array|false
	 */
	public function getConversationMasterById($conversationId)
	{
		return $this->_getDb()->fetchRow('
			SELECT conversation_master.*
			FROM xf_conversation_master AS conversation_master
			WHERE conversation_master.conversation_id = ?
		', $conversationId);
	}

	/**
	 * Gets the specified conversation message record.
	 *
	 * @param integer $messageId
	 *
	 * @return array|false
	 */
	public function getConversationMessageById($messageId)
	{
		return $this->_getDb()->fetchRow('
			SELECT message.*,
				user.*, IF(user.username IS NULL, message.username, user.username) AS username,
				user_profile.*
			FROM xf_conversation_message AS message
			LEFT JOIN xf_user AS user ON
				(user.user_id = message.user_id)
			LEFT JOIN xf_user_profile AS user_profile ON
				(user_profile.user_id = message.user_id)
			WHERE message.message_id = ?
		', $messageId);
	}

	/**
	 * Gets the specified user conversation.
	 *
	 * @param integer $conversationId
	 * @param integer|array $viewingUser Can be a user array, or a user ID (for B.C. purposes)
	 * @param array $fetchOptions Options for extra data to fetch
	 *
	 * @return array|false
	 */
	public function getConversationForUser($conversationId, $viewingUser, array $fetchOptions = array())
	{
		if (is_array($viewingUser))
		{
			$this->standardizeViewingUserReference($viewingUser);
			$userId = $viewingUser['user_id'];
		}
		else
		{
			$userId = $viewingUser;
		}

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

		return $this->_getDb()->fetchRow('
			SELECT conversation_master.*,
				conversation_user.*,
				conversation_recipient.recipient_state, conversation_recipient.last_read_date
				' . $joinOptions['selectFields'] . '
			FROM xf_conversation_user AS conversation_user
			INNER JOIN xf_conversation_master AS conversation_master ON
				(conversation_user.conversation_id = conversation_master.conversation_id)
			INNER JOIN xf_conversation_recipient AS conversation_recipient ON
					(conversation_user.conversation_id = conversation_recipient.conversation_id
					AND conversation_user.owner_user_id = conversation_recipient.user_id)
				' . $joinOptions['joinTables'] . '
			WHERE conversation_user.conversation_id = ?
				AND conversation_user.owner_user_id = ?
		', array($conversationId, $userId));
	}

	/**
	 * Gets information about all recipients of a conversation.
	 *
	 * @param integer $conversationId
	 * @param array $fetchOptions Options for extra data to fetch
	 *
	 * @return array Format: [user id] => info
	 */
	public function getConversationRecipients($conversationId, array $fetchOptions = array())
	{
		$sqlClauses = $this->prepareConversationFetchOptions($fetchOptions);

		return $this->fetchAllKeyed('
			SELECT conversation_recipient.*,
				user.*, user_option.*
				' . $sqlClauses['selectFields'] . '
			FROM xf_conversation_recipient AS conversation_recipient
			LEFT JOIN xf_user AS user ON
				(user.user_id = conversation_recipient.user_id)
			LEFT JOIN xf_user_option AS user_option ON
				(user_option.user_id = user.user_id)
			' . $sqlClauses['joinTables'] . '
			WHERE conversation_recipient.conversation_id = ?
			ORDER BY user.username
		', 'user_id', $conversationId, 'deleted');
	}

	/**
	 * Gets info about a single recipient of a conversation.
	 *
	 * @param integer $conversationId
	 * @param integer $userId
	 * @param array $fetchOptions Options for extra data to fetch
	 *
	 * @return array|false
	 */
	public function getConversationRecipient($conversationId, $userId, array $fetchOptions = array())
	{
		$sqlClauses = $this->prepareConversationFetchOptions($fetchOptions);

		return $this->_getDb()->fetchRow('
			SELECT conversation_recipient.*,
				user.*, user_option.*
				' . $sqlClauses['selectFields'] . '
			FROM xf_conversation_recipient AS conversation_recipient
			LEFT JOIN xf_user AS user ON
				(user.user_id = conversation_recipient.user_id)
			LEFT JOIN xf_user_option AS user_option ON
				(user_option.user_id = user.user_id)
			' . $sqlClauses['joinTables'] . '
			WHERE conversation_recipient.conversation_id = ?
				AND conversation_recipient.user_id = ?
		', array($conversationId, $userId));
	}

	/**
	 * Get conversations that a user can see, ordered by the latest message first.
	 *
	 * @param integer $userId
	 * @param array $conditions Conditions for the WHERE clause
	 * @param array $fetchOptions Options for extra data to fetch
	 *
	 * @return array Format: [conversation id] => info
	 */
	public function getConversationsForUser($userId, array $conditions = array(), array $fetchOptions = array())
	{
		$whereClause = $this->prepareConversationConditions($conditions, $fetchOptions);
		$sqlClauses = $this->prepareConversationFetchOptions($fetchOptions);

		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		$sql = $this->limitQueryResults(
			'
				SELECT conversation_master.*,
					conversation_user.*,
					conversation_starter.*,
					conversation_master.username AS username,
					conversation_recipient.recipient_state, conversation_recipient.last_read_date
					' . $sqlClauses['selectFields'] . '
				FROM xf_conversation_user AS conversation_user
				INNER JOIN xf_conversation_master AS conversation_master ON
					(conversation_user.conversation_id = conversation_master.conversation_id)
				INNER JOIN xf_conversation_recipient AS conversation_recipient ON
					(conversation_user.conversation_id = conversation_recipient.conversation_id
					AND conversation_user.owner_user_id = conversation_recipient.user_id)
				LEFT JOIN xf_user AS conversation_starter ON
					(conversation_starter.user_id = conversation_master.user_id)
					' . $sqlClauses['joinTables'] . '
				WHERE conversation_user.owner_user_id = ?
					AND ' . $whereClause . '
				ORDER BY conversation_user.last_message_date DESC
			', $limitOptions['limit'], $limitOptions['offset']
		);

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

	/**
	 * Gets the total number of conversations that a user has.
	 *
	 * @param integer $userId
	 * @param array $conditions Conditions for the WHERE clause
	 * @param array $fetchConditions Only used in tandem with $conditions at this point
	 *
	 * @return integer
	 */
	public function countConversationsForUser($userId, array $conditions = array())
	{
		$fetchOptions = array();
		$whereClause = $this->prepareConversationConditions($conditions, $fetchOptions);
		$sqlClauses = $this->prepareConversationFetchOptions($fetchOptions);

		return $this->_getDb()->fetchOne('
			SELECT COUNT(*)
			FROM xf_conversation_user AS conversation_user
			INNER JOIN xf_conversation_master AS conversation_master ON
				(conversation_user.conversation_id = conversation_master.conversation_id)
			INNER JOIN xf_conversation_recipient AS conversation_recipient ON
				(conversation_user.conversation_id = conversation_recipient.conversation_id
				AND conversation_user.owner_user_id = conversation_recipient.user_id)
				' . $sqlClauses['joinTables'] . '
			WHERE conversation_user.owner_user_id = ?
				AND ' . $whereClause
		, $userId);
	}

	/**
	 * Get the specified conversations that a user can see, ordered by last message first.
	 *
	 * @param integer $userId
	 * @param array $conversationIds
	 *
	 * @return array Format: [conversation id] => info
	 */
	public function getConversationsForUserByIds($userId, array $conversationIds)
	{
		if (!$conversationIds)
		{
			return array();
		}

		return $this->fetchAllKeyed('
			SELECT conversation_master.*,
				conversation_user.*,
				conversation_starter.*,
				conversation_master.username AS username,
				conversation_recipient.recipient_state, conversation_recipient.last_read_date
			FROM xf_conversation_user AS conversation_user
			INNER JOIN xf_conversation_master AS conversation_master ON
				(conversation_user.conversation_id = conversation_master.conversation_id)
			INNER JOIN xf_conversation_recipient AS conversation_recipient ON
				(conversation_user.conversation_id = conversation_recipient.conversation_id
				AND conversation_user.owner_user_id = conversation_recipient.user_id)
			LEFT JOIN xf_user AS conversation_starter ON
				(conversation_starter.user_id = conversation_master.user_id)
			WHERE conversation_user.owner_user_id = ?
				AND conversation_user.conversation_id IN (' . $this->_getDb()->quote($conversationIds) . ')
			ORDER BY conversation_user.last_message_date DESC
		', 'conversation_id', $userId);
	}

	/**
	 * Return a list of users that have received conversations that the specified user has receved,
	 * where the recieving user matches the given username search string
	 *
	 * @param integer $userId
	 * @param string $searchString
	 *
	 * @return array
	 */
	public function findConversationRecipientsForUser($userId, $searchString)
	{
		$userIds = $this->_getDb()->fetchCol('
			SELECT DISTINCT recp1.user_id
			FROM xf_conversation_recipient AS recp1
			INNER JOIN xf_conversation_recipient AS recp2 ON
				(recp2.conversation_id = recp1.conversation_id
				AND recp2.user_id = ?
				AND recp2.recipient_state = \'active\')
		', $userId);

		return $this->_getUsersMatchingCriteria($userIds, $searchString);
	}

	/**
	 * Return a list of users that have started conversations that the specified user has receved,
	 * where the starting user matches the given username search string
	 *
	 * @param integer $userId
	 * @param string $searchString
	 *
	 * @return array
	 */
	public function findConversationStartersForUser($userId, $searchString)
	{
		$userIds = $this->_getDb()->fetchCol('
			SELECT DISTINCT conversation_master.user_id
			FROM xf_conversation_master AS conversation_master
			INNER JOIN xf_conversation_user AS conversation_user ON
				(conversation_user.conversation_id = conversation_master.conversation_id
				AND conversation_user.owner_user_id = ?)
		', $userId);

		return $this->_getUsersMatchingCriteria($userIds, $searchString);
	}

	/**
	 * Return a list of users that have posted in conversations that the specified user has receved,
	 * where the recieving user matches the given username search string
	 *
	 * @param integer $userId
	 * @param string $searchString
	 *
	 * @return array
	 */
	public function findConversationRespondersForUser($userId, $searchString)
	{
		$userIds = $this->_getDb()->fetchCol('
			SELECT DISTINCT conversation_message.user_id
			FROM xf_conversation_message AS conversation_message
			INNER JOIN xf_conversation_user AS conversation_user ON
				(conversation_user.conversation_id = conversation_message.conversation_id)
			WHERE conversation_user.owner_user_id = ?
		', $userId);

		return $this->_getUsersMatchingCriteria($userIds, $searchString);
	}

	/**
	 * Fetches a list of users matching the user ID and user name search criteria.
	 * Used in conjunction with this class's findConversation[x]ForUser() methods.
	 *
	 * @param array $userIds
	 * @param string $searchString
	 *
	 * @return array
	 */
	protected function _getUsersMatchingCriteria(array $userIds, $searchString)
	{
		if ($userIds)
		{
			return $this->_getUserModel()->getUsers(array(
				'user_id' => $userIds,
				'username' => array($searchString , 'r')
			));
		}

		return array();
	}

	/**
	 * Gets conversation IDs in the specified range. The IDs returned will be those immediately
	 * after the "start" value (not including the start), up to the specified limit.
	 *
	 * @param integer $start IDs greater than this will be returned
	 * @param integer $limit Number of posts to return
	 *
	 * @return array List of IDs
	 */
	public function getConversationIdsInRange($start, $limit)
	{
		$db = $this->_getDb();

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

	public function prepareConversationFetchOptions(array $fetchOptions)
	{
		$selectFields = '';
		$joinTables = '';
		$db = $this->_getDb();

		if (!empty($fetchOptions['join']))
		{
			if ($fetchOptions['join'] & self::FETCH_LAST_MESSAGE_AVATAR)
			{
				$selectFields .= ',
					last_message_user.avatar_date AS last_message_avatar_date,
					last_message_user.gender AS last_message_gender,
					last_message_user.gravatar AS last_message_gravatar';
				$joinTables .= '
					LEFT JOIN xf_user AS last_message_user ON
						(last_message_user.user_id = conversation_user.last_message_user_id)';
			}

			if ($fetchOptions['join'] & self::FETCH_FIRST_MESSAGE)
			{
				$selectFields .= ',
					conversation_message.message';
				$joinTables .= '
					INNER JOIN xf_conversation_message AS conversation_message ON
						(conversation_message.message_id = conversation_master.first_message_id)';
			}
		}

		if (!empty($fetchOptions['receivedUserId']))
		{
			$joinTables .= '
				INNER JOIN xf_conversation_recipient AS check_recipient ON
					(check_recipient.conversation_id = conversation_master.conversation_id
					AND check_recipient.user_id = ' . $db->quote($fetchOptions['receivedUserId']) . ')';
		}

		if (isset($fetchOptions['draftUserId']))
		{
			if (!empty($fetchOptions['draftUserId']))
			{
				$selectFields .= ',
					draft.message AS draft_message, draft.extra_data AS draft_extra';
				$joinTables .= '
					LEFT JOIN xf_draft AS draft
						ON (draft.draft_key = CONCAT(\'conversation-\', conversation_master.conversation_id)
						AND draft.user_id = ' . $db->quote($fetchOptions['draftUserId']) . ')';
			}
			else
			{
				$selectFields .= ',
					\'\' AS draft_message, NULL AS draft_extra';
			}
		}

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

	/**
	 * Prepares a set of conditions against which to select conversations.
	 *
	 * @param array $conditions List of conditions.
	 * --popupMode (boolean) constrains results to unread, or sent within timeframe specified by options->conversationPopupExpiryHours
	 * @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 prepareConversationConditions(array $conditions, array &$fetchOptions)
	{
		$sqlConditions = array();
		$db = $this->_getDb();

		if (!empty($conditions['popupMode']))
		{
			$cutOff = XenForo_Application::$time - 3600 * XenForo_Application::get('options')->conversationPopupExpiryHours;
			$sqlConditions[] = 'conversation_user.is_unread = 1 OR conversation_user.last_message_date > ' . $cutOff;
		}

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

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

		if (!empty($conditions['last_message_date']) && is_array($conditions['last_message_date']))
		{
			list($operator, $cutOff) = $conditions['last_message_date'];

			$this->assertValidCutOffOperator($operator);
			$sqlConditions[] = "conversation_user.last_message_date $operator " . $db->quote($cutOff);
		}

		if (!empty($conditions['search_type']) && !empty($conditions['search_user_id']))
		{
			switch ($conditions['search_type'])
			{
				case 'started_by':
					$sqlConditions[] = 'conversation_master.user_id = ' . $db->quote($conditions['search_user_id']);
					break;

				case 'received_by':
					$fetchOptions['receivedUserId'] = $conditions['search_user_id'];
					break;
			}
		}

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

	/**
	 * Get messages within a given conversation.
	 *
	 * @param integer $conversationId
	 * @param array $fetchOptions Options for extra data to fetch
	 *
	 * @return array Format [message id] => info
	 */
	public function getConversationMessages($conversationId, array $fetchOptions = array())
	{
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT message.*,
					user.*, IF(user.username IS NULL, message.username, user.username) AS username,
					user_profile.*
				FROM xf_conversation_message AS message
				LEFT JOIN xf_user AS user ON
					(user.user_id = message.user_id)
				LEFT JOIN xf_user_profile AS user_profile ON
					(user_profile.user_id = message.user_id)
				WHERE message.conversation_id = ?
				ORDER BY message.message_date
			', $limitOptions['limit'], $limitOptions['offset']
		), 'message_id', $conversationId);
	}

	/**
	 * Finds the newest conversation messages after the specified date.
	 *
	 * @param integer $conversationId
	 * @param integer $date
	 * @param array $fetchOptions
	 *
	 * @return array [message id] => info
	 */
	public function getNewestConversationMessagesAfterDate($conversationId, $date, array $fetchOptions = array())
	{
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		return $this->fetchAllKeyed($this->limitQueryResults(
			'
				SELECT message.*,
					user.*, IF(user.username IS NULL, message.username, user.username) AS username,
					user_profile.*
				FROM xf_conversation_message AS message
				LEFT JOIN xf_user AS user ON
					(user.user_id = message.user_id)
				LEFT JOIN xf_user_profile AS user_profile ON
					(user_profile.user_id = message.user_id)
				WHERE message.conversation_id = ?
					AND message.message_date > ?
				ORDER BY message.message_date DESC
			', $limitOptions['limit'], $limitOptions['offset']
		), 'message_id', array($conversationId, $date));
	}

	/**
	 * Gets the next message in a conversation, post after the specified date. This is useful
	 * for finding the first unread message, for example.
	 *
	 * @param integer $conversationId
	 * @param integer $messageDate Finds first message posted after this
	 *
	 * @return array|false
	 */
	public function getNextMessageInConversation($conversationId, $messageDate)
	{
		$db = $this->_getDb();

		return $db->fetchRow($db->limit('
			SELECT *
			FROM xf_conversation_message
			WHERE conversation_id = ?
				AND message_date > ?
			ORDER BY message_date
		', 1), array($conversationId, $messageDate));
	}

	/**
	 * Count the number of messages before a given date in a conversation.
	 *
	 * @param integer $conversationId
	 * @param integer $messageDate
	 *
	 * @return integer
	 */
	public function countMessagesBeforeDateInConversation($conversationId, $messageDate)
	{
		return $this->_getDb()->fetchOne('
			SELECT COUNT(*)
			FROM xf_conversation_message AS conversation_message
			WHERE conversation_message.conversation_id = ?
				AND conversation_message.message_date < ?
		', array($conversationId, $messageDate));
	}

	/**
	 * Prepare a conversation for display or further processing.
	 *
	 * @param array $conversation
	 *
	 * @return array
	 */
	public function prepareConversation(array $conversation)
	{
		$conversation['isNew'] = ($conversation['last_message_date'] > $conversation['last_read_date']);
		$conversation['title'] = XenForo_Helper_String::censorString($conversation['title']);

		$conversation['lastPageNumbers'] = $this->getLastPageNumbers($conversation['reply_count']);

		$conversation['last_message'] = array(
			'message_id' => $conversation['last_message_id'],
			'message_date' => $conversation['last_message_date'],
			'user_id' => $conversation['last_message_user_id'],
			'username' => $conversation['last_message_username']
		);

		if (isset($conversation['last_message_avatar_date']))
		{
			$conversation['last_message']['avatar_date'] = $conversation['last_message_avatar_date'];
		}

		if (isset($conversation['last_message_gender']))
		{
			$conversation['last_message']['gender'] = $conversation['last_message_gender'];
		}

		if (isset($conversation['last_message_gravatar']))
		{
			$conversation['last_message']['gravatar'] = $conversation['last_message_gravatar'];
		}

		if (array_key_exists('user_group_id', $conversation))
		{
			$conversation = $this->_getUserModel()->prepareUser($conversation);
			$conversation['isIgnored'] = false; // don't ignore conversations - the user can leave the conversation instead
		}

		$conversation['recipientNames'] = $conversation['recipients'] ? @unserialize($conversation['recipients']) : array();

		return $conversation;
	}

	/**
	 * Prepare a collection of conversations for display or further processing.
	 *
	 * @param array $conversations
	 *
	 * @return array
	 */
	public function prepareConversations(array $conversations)
	{
		foreach ($conversations AS &$conversation)
		{
			$conversation = $this->prepareConversation($conversation);
		}

		return $conversations;
	}

	/**
	 * Prepare a message for display or further processing.
	 *
	 * @param array $message
	 * @param array $conversation
	 *
	 * @return array Prepared message
	 */
	public function prepareMessage(array $message, array $conversation)
	{
		$message['isNew'] = ($message['message_date'] > $conversation['last_read_date']);

		$message['canEdit'] = $this->canEditMessage($message, $conversation);

		$message['canReport'] = $this->canReportMessage($message, $conversation);

		if (array_key_exists('user_group_id', $message))
		{
			$message = $this->_getUserModel()->prepareUser($message);
			$message['isIgnored'] = false; // don't ignore messages in conversations - the user can leave the conversation instead
		}

		return $message;
	}

	/**
	 * Prepare a collection of messages (in the same conversation) for display or
	 * further processing.
	 *
	 * @param array $messages
	 * @param array $conversation
	 *
	 * @return array Prepared messages
	 */
	public function prepareMessages(array $messages, array $conversation)
	{
		$pagePosition = 0;

		foreach ($messages AS &$message)
		{
			$message = $this->prepareMessage($message, $conversation);

			$message['position_on_page'] = ++$pagePosition;
		}

		return $messages;
	}

	/**
	 * Gets the maximum message date in a list of messages.
	 *
	 * @param array $messages
	 *
	 * @return integer Max message date timestamp; 0 if no messages
	 */
	public function getMaximumMessageDate(array $messages)
	{
		$max = 0;
		foreach ($messages AS $message)
		{
			if ($message['message_date'] > $max)
			{
				$max = $message['message_date'];
			}
		}

		return $max;
	}

	/**
	 * Add the details of a new conversation reply to conversation recipients.
	 *
	 * @param array $conversation Conversation info
	 * @param array|null $replyUser Information about the user who replied
	 * @param array|null $messageInfo Array containing 'message', which is the text the message being sent
	 *
	 * @return array $recipients
	 */
	public function addConversationReplyToRecipients(array $conversation, array $replyUser = null, array $messageInfo = null)
	{
		$db = $this->_getDb();
		XenForo_Db::beginTransaction($db);

		$extraData = array('message_id' => $conversation['last_message_id']);

		$recipients = $this->getConversationRecipients($conversation['conversation_id']);
		foreach ($recipients AS $recipient)
		{
			if (empty($recipient['user_id']))
			{
				continue; // deleted user
			}

			switch ($recipient['recipient_state'])
			{
				case 'active':
					$db->query('
						UPDATE xf_conversation_user SET
							is_unread = 1,
							reply_count = ' . $db->quote($conversation['reply_count']) . ',
							last_message_date = ' . $db->quote($conversation['last_message_date']) . ',
							last_message_id = ' . $db->quote($conversation['last_message_id']) . ',
							last_message_user_id = ' . $db->quote($conversation['last_message_user_id']) . ',
							last_message_username = ' . $db->quote($conversation['last_message_username']) . '
						WHERE conversation_id = ?
							AND owner_user_id = ?
					', array($conversation['conversation_id'], $recipient['user_id']));

					$this->rebuildUnreadConversationCountForUser($recipient['user_id']);

					$this->insertConversationAlert($conversation, $recipient, 'reply', $replyUser, $extraData, $messageInfo);
					break;

				case 'deleted':
					$this->insertConversationRecipient($conversation, $recipient['user_id'], $recipient);
					$this->insertConversationAlert($conversation, $recipient, 'reply', $replyUser, $extraData, $messageInfo);
					break;
			}
		}

		XenForo_Db::commit($db);

		return $recipients;
	}

	/**
	 * Insert a new conversation recipient record.
	 *
	 * @param array $conversation Conversation info
	 * @param integer $user User to insert for
	 * @param array $existingRecipient Information about the existing recipient record (if there is one)
	 * @param string $insertState State to insert the conversation for with this user
	 *
	 * @return boolean True if an insert was required (may be false if user is already an active recipient or is ignoring)
	 */
	public function insertConversationRecipient(array $conversation, $userId, array $existingRecipient = null, $insertState = 'active')
	{
		if ($existingRecipient === null)
		{
			$existingRecipient = $this->getConversationRecipient($conversation['conversation_id'], $userId);
		}

		if ($existingRecipient)
		{
			if (empty($existingRecipient['user_id'])
				|| $existingRecipient['recipient_state'] == 'deleted_ignored'
				|| $existingRecipient['recipient_state'] == $insertState)
			{
				return false;
			}
		}

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

		$rowsAffected = $db->query('
			INSERT INTO xf_conversation_recipient
				(conversation_id, user_id, recipient_state, last_read_date)
			VALUES
				(?, ?, ?, 0)
			ON DUPLICATE KEY UPDATE recipient_state = VALUES(recipient_state)
		', array($conversation['conversation_id'], $userId, $insertState))->rowCount();

		$inserted = ($rowsAffected == 1);

		if ($insertState == 'active')
		{
			$db->query('
				INSERT IGNORE INTO xf_conversation_user
					(conversation_id, owner_user_id, is_unread, reply_count,
					last_message_date, last_message_id, last_message_user_id, last_message_username)
				VALUES
					(?, ?, 1, ?,
					?, ?, ?, ?)
			', array(
				$conversation['conversation_id'], $userId, $conversation['reply_count'],
				$conversation['last_message_date'], $conversation['last_message_id'],
				$conversation['last_message_user_id'], $conversation['last_message_username']
			));

			$this->rebuildUnreadConversationCountForUser($userId);

			if ($inserted)
			{
				$db->query('
					UPDATE xf_conversation_master SET
						recipient_count = recipient_count + 1
					WHERE conversation_id = ?
				', $conversation['conversation_id']);
			}
		}

		XenForo_Db::commit($db);

		return ($insertState == 'active');
	}

	/**
	 * Inserts an alert for this conversation.
	 *
	 * @param array $conversation
	 * @param array $alertUser User to notify
	 * @param string $action Action taken out (values: insert, reply, join)
	 * @param array|null $triggerUser User triggering the alert; defaults to last user to reply
	 * @param array|null $extraData
	 * @param array|null $messageInfo Array containing the text of the message being sent (if applicable) as 'message'
	 */
	public function insertConversationAlert(array $conversation, array $alertUser, $action,
		array $triggerUser = null, array $extraData = null, array &$messageInfo = null
	)
	{
		if (!$triggerUser)
		{
			$triggerUser = array(
				'user_id' => $conversation['last_message_user_id'],
				'username' => $conversation['last_message_username']
			);
		}

		if ($triggerUser['user_id'] == $alertUser['user_id'])
		{
			return;
		}

		if ($alertUser['email_on_conversation'] && $alertUser['user_state'] == 'valid' && !$alertUser['is_banned'])
		{
			if (!isset($conversation['titleCensored']))
			{
				$conversation['titleCensored'] = XenForo_Helper_String::censorString($conversation['title']);
			}

			if (isset($messageInfo['message']) && XenForo_Application::get('options')->emailConversationIncludeMessage)
			{
				if (!isset($messageInfo['messageText']))
				{
					$bbCodeParserText = XenForo_BbCode_Parser::create(XenForo_BbCode_Formatter_Base::create('Text'));
					$messageInfo['messageText'] = new XenForo_BbCode_TextWrapper($messageInfo['message'], $bbCodeParserText);

					$bbCodeParserHtml = XenForo_BbCode_Parser::create(XenForo_BbCode_Formatter_Base::create('HtmlEmail'));
					$messageInfo['messageHtml'] = new XenForo_BbCode_TextWrapper($messageInfo['message'], $bbCodeParserHtml);
				}

				$emailTemplate = "conversation_{$action}_messagetext";
			}
			else
			{
				$emailTemplate = "conversation_{$action}";
			}

			$mail = XenForo_Mail::create($emailTemplate, array(
				'receiver' => $alertUser,
				'sender' => $triggerUser,
				'options' => XenForo_Application::get('options'),
				'conversation' => $conversation,
				'message' => $messageInfo,
			), $alertUser['language_id']);

			$mail->enableAllLanguagePreCache();
			$mail->queue($alertUser['email'], $alertUser['username']);
		}
	}

	/**
	 * Delets a conversation record for a specific user. If all users have deleted the conversation,
	 * it will be completely removed.
	 *
	 * @param integer $conversationId
	 * @param integer $userId
	 * @param string $deleteType Type of deletion (either delete, or delete_ignore)
	 */
	public function deleteConversationForUser($conversationId, $userId, $deleteType)
	{
		$recipientState = ($deleteType == 'delete_ignore' ? 'deleted_ignored' : 'deleted');

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

		$db->update('xf_conversation_recipient',
			array('recipient_state' => $recipientState),
			'conversation_id = ' . $db->quote($conversationId) . ' AND user_id = ' . $db->quote($userId)
		);
		$db->delete('xf_conversation_user',
			'conversation_id = ' . $db->quote($conversationId) . ' AND owner_user_id = ' . $db->quote($userId)
		);
		$db->delete('xf_user_alert',
			'content_type = \'conversation\' AND content_id = ' . $db->quote($conversationId)
				. ' AND alerted_user_id = ' . $db->quote($userId)
		);

		$this->rebuildUnreadConversationCountForUser($userId);

		$haveActive = false;
		foreach ($this->getConversationRecipients($conversationId) AS $recipient)
		{
			if (empty($recipient['user_id']))
			{
				continue; // deleted user
			}

			if ($recipient['recipient_state'] == 'active')
			{
				$haveActive = true;
				break;
			}
		}

		if (!$haveActive)
		{
			// no one has the conversation any more, so delete it
			$dw = XenForo_DataWriter::create('XenForo_DataWriter_ConversationMaster');
			$dw->setExistingData($conversationId);
			$dw->delete();
		}

		XenForo_Db::commit($db);
	}

	/**
	 * Marks the conversation as read to a certain point for a user.
	 *
	 * @param integer $conversationId
	 * @param integer $userId
	 * @param integer $newReadDate Timestamp to mark as read until
	 * @param integer $lastMessageDate Date of last message; only marks whole conversation read if more than this date
	 * @param boolean $updateVisitor If true, reduces the conversations_unread counter for the visitor; should be false for replies
	 */
	public function markConversationAsRead($conversationId, $userId, $newReadDate, $lastMessageDate = 0, $updateVisitor = true)
	{
		$db = $this->_getDb();

		XenForo_Db::beginTransaction($db);

		$this->_updateConversationReadDate($conversationId, $userId, $newReadDate, $db);

		if ($newReadDate >= $lastMessageDate)
		{
			$rowsChanged = $db->update('xf_conversation_user',
				array('is_unread' => 0),
				'conversation_id = ' . $db->quote($conversationId) . ' AND owner_user_id = ' . $db->quote($userId)
			);
			if ($rowsChanged)
			{
				$db->query('
					UPDATE xf_user SET
						conversations_unread = IF(conversations_unread > 1, conversations_unread - 1, 0)
					WHERE user_id = ?
				', $userId);

				$visitor = XenForo_Visitor::getInstance();
				if ($updateVisitor && $userId == $visitor['user_id'] && $visitor['conversations_unread'] >= 1)
				{
					$visitor['conversations_unread'] -= 1;
				}
			}
		}

		XenForo_Db::commit($db);
	}

	/**
	 * Marks the conversation as (completely) unread for a user.
	 *
	 * @param integer $conversationId
	 * @param integer $userId
	 */
	public function markConversationAsUnread($conversationId, $userId)
	{
		$db = $this->_getDb();

		XenForo_Db::beginTransaction($db);

		$this->_updateConversationReadDate($conversationId, $userId, 0, $db);

		$rowsChanged = $db->update('xf_conversation_user',
			array('is_unread' => 1),
			'conversation_id = ' . $db->quote($conversationId) . ' AND owner_user_id = ' . $db->quote($userId)
		);
		if ($rowsChanged)
		{
			$db->query('
				UPDATE xf_user SET
					conversations_unread = conversations_unread + 1
				WHERE user_id = ?
			', $userId);

			$visitor = XenForo_Visitor::getInstance();
			if ($userId == $visitor['user_id'])
			{
				$visitor['conversations_unread'] += 1;
			}
		}

		XenForo_Db::commit($db);
	}

	public function changeConversationStarState($conversationId, $userId, $starred)
	{
		$db = $this->_getDb();
		return (bool)$db->update('xf_conversation_user',
			array('is_starred' => $starred ? 1 : 0),
			'conversation_id = ' . $db->quote($conversationId) . ' AND owner_user_id = ' . $db->quote($userId)
		);
	}

	/**
	 * Update the read date record for a conversation
	 *
	 * @param integer $conversationId
	 * @param integer $userId
	 * @param integer $newReadDate
	 * @param Zend_Db_Adapter_Abstract $db
	 *
	 * @return integer $newReadDate
	 */
	protected function _updateConversationReadDate($conversationId, $userId, $newReadDate, Zend_Db_Adapter_Abstract $db = null)
	{
		if (empty($db))
		{
			$db = $this->_getDb();
		}

		$db->update('xf_conversation_recipient', array('last_read_date' => $newReadDate),
			'conversation_id = ' . $db->quote($conversationId) . ' AND user_id = ' . $db->quote($userId)
		);

		return $newReadDate;
	}

	/**
	 * Gets the count of unread conversations for the specified user.
	 *
	 * @param integer $userId
	 *
	 * @return integer
	 */
	public function countUnreadConversationsForUser($userId)
	{
		return $this->_getDb()->fetchOne('
			SELECT COUNT(*)
			FROM xf_conversation_user AS conversation_user
			INNER JOIN xf_conversation_master AS conversation_master ON
				(conversation_user.conversation_id = conversation_master.conversation_id)
			INNER JOIN xf_conversation_recipient AS conversation_recipient ON
					(conversation_user.conversation_id = conversation_recipient.conversation_id
					AND conversation_user.owner_user_id = conversation_recipient.user_id)
			WHERE conversation_user.owner_user_id = ?
				AND conversation_user.is_unread = 1
		', $userId);
	}

	/**
	 * Recalculates the unread conversation count for the specified user.
	 *
	 * @param integer $userId
	 */
	public function rebuildUnreadConversationCountForUser($userId)
	{
		$db = $this->_getDb();
		$db->update('xf_user', array(
			'conversations_unread' => $this->countUnreadConversationsForUser($userId)
		), 'user_id = ' . $db->quote($userId));
	}

	/**
	 * 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)
	{
		// moved for easier access on all pages
		return $this->_getUserModel()->canStartConversations($errorPhraseKey, $viewingUser);
	}

	/**
	 * 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)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if ($user['user_id'] == $viewingUser['user_id'])
		{
			$errorPhraseKey = 'you_may_not_start_conversation_with_yourself';
			return false;
		}

		if (empty($user['permissions']))
		{
			if (empty($user['global_permission_cache']))
			{
				$user['global_permission_cache'] = $this->_getDb()->fetchOne('
					SELECT cache_value
					FROM xf_permission_combination
					WHERE permission_combination_id = ?
				', $user['permission_combination_id']);
			}

			$user['permissions'] = XenForo_Permission::unserializePermissions($user['global_permission_cache']);
		}

		$userModel = $this->_getUserModel();

		if (!$userModel->canBypassUserPrivacy($null, $viewingUser)
			&& !XenForo_Permission::hasPermission($user['permissions'], 'conversation', 'receive')
		)
		{
			return false;
		}

		return (
			$this->canStartConversations($errorPhraseKey, $viewingUser)
			&& !$user['is_banned']
			&& $userModel->passesPrivacyCheck(
				$user['allow_send_personal_conversation'], $user, $viewingUser
			)
		);
	}

	/**
	 * Determines if the specified user can reply to the conversation.
	 * Does not check conversation viewing permissions.
	 *
	 * @param array $conversation
	 * @param string $errorPhraseKey Returned phrase key for a specific error
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canReplyToConversation(array $conversation, &$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		return ($conversation['user_id'] == $viewingUser['user_id'] || $conversation['conversation_open']);
	}

	/**
	 * Determines if the specified user can edit the conversation.
	 * Does not check conversation viewing permissions.
	 *
	 * @param array $conversation
	 * @param string $errorPhraseKey Returned phrase key for a specific error
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canEditConversation(array $conversation, &$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		return ($conversation['user_id'] == $viewingUser['user_id']);
	}

	/**
	 * Determines if the specified user can invite users the conversation.
	 * Does not check conversation viewing permissions.
	 *
	 * @param array $conversation
	 * @param string $errorPhraseKey Returned phrase key for a specific error
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canInviteUsersToConversation(array $conversation, &$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

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

		if (!$conversation['conversation_open'])
		{
			return false;
		}

		if ($conversation['user_id'] == $viewingUser['user_id'] || $conversation['open_invite'])
		{
			if (XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'start'))
			{
				$remaining = $this->allowedAdditionalConversationRecipients($conversation, $viewingUser);
				return ($remaining == -1 || $remaining >= 1);
			}
		}

		return false;
	}

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

		// moderator permission, so ignore conversation open/closed and time limit
		if (XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'editAnyPost'))
		{
			return true;
		}

		// no editing of messages in a closed conversation by normal users
		if (!$conversation['conversation_open'])
		{
			$errorPhraseKey = 'conversation_is_closed';
			return false;
		}

		// own message
		if ($message['user_id'] == $viewingUser['user_id'])
		{
			if (XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'editOwnPost'))
			{
				$editLimit = XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'editOwnPostTimeLimit');

				if ($editLimit != -1 && (!$editLimit || $message['message_date'] < XenForo_Application::$time - 60 * $editLimit))
				{
					$errorPhraseKey = array('message_edit_time_limit_expired', 'minutes' => $editLimit);
					return false;
				}

				return true;
			}
		}

		$errorPhraseKey = 'you_may_not_edit_this_message';
		return false;
	}

	/**
	 * Checks that the viewing user may report the specified conversation message
	 *
	 * @param array $message
	 * @param array $conversation
	 * @param string
	 * @param boolean $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canReportMessage(array $message, array $conversation, &$errorPhraseKey = '', array $viewingUser = null)
	{
		return $this->_getUserModel()->canReportContent($errorPhraseKey, $viewingUser);
	}

	/**
	 * Check permission to view a reported conversation
	 *
	 * @param array $message
	 * @param array $conversation
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canManageReportedMessage(array $message, array $conversation, &$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

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

		$errorPhraseKey = 'you_may_not_manage_this_reported_content';
		return false;
	}

	/**
	 * Determines if a new attachment can be posted in the specified conversation,
	 * with the given permissions. If no permissions are specified, permissions
	 * are retrieved from the currently visiting user. This does not check viewing permissions.
	 *
	 * @param array $conversation Info about the conversation posting in
	 * @param string $errorPhraseKey Returned phrase key for a specific error
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canUploadAndManageAttachment(array $conversation = array(), &$errorPhraseKey = '', array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

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

		if ($conversation)
		{
			// must be able to reply to the conversation in order to upload an attachment
			if (!$this->canReplyToConversation($conversation, $errorPhraseKey, $viewingUser))
			{
				return false;
			}
		}
		else if (!$this->canStartConversations($errorPhraseKey, $viewingUser))
		{
			return false;
		}

		return XenForo_Permission::hasPermission($viewingUser['permissions'], 'conversation', 'uploadAttachment');
	}

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

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

		return ($conversation['owner_user_id'] == $viewingUser['user_id']);
	}

	/**
	 * Determines if the specified user can view an attachment to the specified message
	 *
	 * @param array $message
	 * @param array $conversation
	 * @param string $errorPhraseKey
	 * @param array|null $viewingUser
	 *
	 * @return boolean
	 */
	public function canViewAttachmentOnConversationMessage(array $message, array $conversation, &$errorPhraseKey = '', array $viewingUser = null)
	{
		return $this->canViewAttachmentOnConversation($conversation, $errorPhraseKey, $viewingUser);
	}

	/**
	 * Calculates the allowed number of additional conversation receiptions the
	 * viewing user can add to the given conversation.
	 * @param array $conversation Conversation; if empty array, assumes new conversation
	 * @param array|null $viewingUser
	 *
	 * @return integer -1 means unlimited; 0 is no more invites; other is remaining count
	 */
	public function allowedAdditionalConversationRecipients(array $conversation, array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

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

		if ($conversation)
		{
			$remaining = ($maxRecipients - $conversation['recipient_count'] + 1); // +1 represents self; self doesn't count
			return max(0, $remaining);
		}
		else
		{
			return $maxRecipients;
		}
	}

	/**
	 * Gets the quote text for the specified conversation message.
	 *
	 * @param array $message
	 * @param integer $maxQuoteDepth Max depth of quotes (-1 for unlimited)
	 *
	 * @return string
	 */
	public function getQuoteForConversationMessage(array $message, $maxQuoteDepth = 0)
	{
		return '[QUOTE="' . $message['username']
			. ', convMessage: ' . $message['message_id']
			. (!empty($message['user_id']) ? ', member: ' . $message['user_id'] : '')
			. '"]'
			. trim(XenForo_Helper_String::stripQuotes($message['message'], $maxQuoteDepth))
			. "[/QUOTE]\n";
	}

	/**
	 * Returns the last few page numbers of a conversation
	 *
	 * @param integer $replyCount
	 *
	 * @return array|boolean
	 */
	public function getLastPageNumbers($replyCount)
	{
		$perPage = XenForo_Application::get('options')->messagesPerPage;

		if (($replyCount +1) > $perPage)
		{
			return XenForo_Helper_Discussion::getLastPageNumbers($replyCount, $perPage);
		}
		else
		{
			return false;
		}
	}

	/**
	 * Gets the set of attachment params required to allow uploading.
	 *
	 * @param array $conversation
	 * @param array $contentData Information about the content, for URL building
	 * @param array|null $viewingUser
	 * @param string|null $tempHash
	 *
	 * @return array|bool
	 */
	public function getAttachmentParams(array $conversation = array(), array $contentData = array(), array $viewingUser = null, $tempHash = null)
	{
		if ($this->canUploadAndManageAttachment($conversation, $null, $viewingUser))
		{
			$existing = is_string($tempHash) && strlen($tempHash) == 32;
			$output = array(
				'hash' => $existing ? $tempHash : md5(uniqid('', true)),
				'content_type' => 'conversation_message',
				'content_data' => $contentData
			);
			if ($existing)
			{
				$attachmentModel = $this->getModelFromCache('XenForo_Model_Attachment');
				$output['attachments'] = $attachmentModel->prepareAttachments(
					$attachmentModel->getAttachmentsByTempHash($tempHash)
				);
			}

			return $output;
		}
		else
		{
			return false;
		}
	}

	/**
	 * Gets the attachments that belong to the given messages, and merges them in with
	 * their parent message (in the attachments key). The attachments key will not be
	 * set if no attachments are found for the message.
	 *
	 * @param array $messages
	 *
	 * @return array Messages, with attachments added where necessary
	 */
	public function getAndMergeAttachmentsIntoConversationMessages(array $messages)
	{
		$messageIds = array();

		foreach ($messages AS $messageId => $message)
		{
			if ($message['attach_count'])
			{
				$messageIds[] = $messageId;
			}
		}

		if ($messageIds)
		{
			$attachmentModel = $this->_getAttachmentModel();

			$attachments = $attachmentModel->getAttachmentsByContentIds('conversation_message', $messageIds);

			foreach ($attachments AS $attachment)
			{
				$messages[$attachment['content_id']]['attachments'][$attachment['attachment_id']] = $attachmentModel->prepareAttachment($attachment);
			}
		}

		return $messages;
	}

	/**
	 * @param integer $userId
	 *
	 * @return array
	 */
	public function getConversationsStartedByUser($userId)
	{
		return $this->fetchAllKeyed("
			SELECT *
			FROM xf_conversation_master
			WHERE user_id = ?
			ORDER BY start_date DESC
		", 'conversation_id', $userId);
	}

	/**
	 * @param integer $userId
	 *
	 * @return int Total conversations deleted
	 */
	public function deleteConversationsStartedByUser($userId)
	{
		$conversations = $this->getConversationsStartedByUser($userId);
		$i = 0;
		foreach ($conversations AS $conversation)
		{
			$dw = XenForo_DataWriter::create('XenForo_DataWriter_ConversationMaster');
			$dw->setExistingData($conversation, true);
			$dw->delete();
			$i++;
		}

		return $i;
	}

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

	/**
	 * @return XenForo_Model_Attachment.
	 */
	protected function _getAttachmentModel()
	{
		return $this->getModelFromCache('XenForo_Model_Attachment');
	}

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