<?php

/**
 * Search model.
 *
 * @package XenForo_Search
 */
class XenForo_Model_Search extends XenForo_Model
{
	const CONTENT_TYPE = 0;
	const CONTENT_ID = 1;

	/**
	 * Gets the specified search.
	 *
	 * @param integer $searchId
	 *
	 * @return array|false
	 */
	public function getSearchById($searchId)
	{
		return $this->_getDb()->fetchRow('
			SELECT *
			FROM xf_search
			WHERE search_id = ?
		', $searchId);
	}

	/**
	 * Inserts the specified search.
	 *
	 * @param array $results List of results, in format [] => array(content type, content id)
	 * @param string $searchType The type of the search (usually content type or blank, but could be general string)
	 * @param string $searchQuery Text that was queried for
	 * @param array $constraints Additional search constraints
	 * @param string $order Search sort order
	 * @param boolean $groupByDiscussion True if results should be folded up into their discussion (or other container)
	 * @param array $userResults List of user IDs that matched the keyword search
	 * @param array $warnings Any search warnings that occurred
	 * @param integer|null $userId User doing search
	 * @param integer|null $searchDate Time of search or null for now
	 *
	 * @return array Search info, including search_id
	 */
	public function insertSearch(array $results, $searchType, $searchQuery, array $constraints, $order, $groupByDiscussion,
		array $userResults = array(), array $warnings = array(), $userId = null, $searchDate = null
	)
	{
		if ($userId === null)
		{
			$userId = XenForo_Visitor::getUserId();
		}

		if ($searchDate === null)
		{
			$searchDate = XenForo_Application::$time;
		}

		$searchType = utf8_substr($searchType, 0, 50);
		$order = utf8_substr($order, 0, 50);

		$search = array(
			'search_results' => json_encode(array_values($results)),
			'result_count' => count($results),
			'search_type' => $searchType,
			'search_query' => utf8_substr($searchQuery, 0, 200),
			'search_constraints' => json_encode($constraints),
			'search_order' => $order,
			'search_grouping' => $groupByDiscussion ? 1 : 0,
			'user_results' => implode(',', $userResults),
			'warnings' => json_encode(array_map('strval', $warnings)),
			'user_id' => $userId,
			'search_date' => $searchDate,
			'query_hash' => $this->getSearchQueryHash($searchType, $searchQuery, $constraints, $order, $groupByDiscussion)
		);

		$this->_getDb()->insert('xf_search', $search);

		$search['search_id'] = $this->_getDb()->lastInsertId();

		return $search;
	}

	/**
	 * Gets a search that matches the given criteria.
	 *
	 * @param string $searchType The type of the search (usually content type or blank, but could be general string)
	 * @param string $searchQuery Text being queried for
	 * @param array $constraints Search constraints
	 * @param string $order Search order
	 * @param boolean $groupByDiscussion True if results should be folded up into their discussion (or other container)
	 * @param integer $userId User ID doing the search
	 * @param boolean $forceUsage True to force the usage of the cache (in debug mode)
	 *
	 * @return array|false
	 */
	public function getExistingSearch($searchType, $searchQuery, array $constraints, $order, $groupByDiscussion, $userId, $forceUsage = false)
	{
		if (XenForo_Application::debugMode() && !$forceUsage)
		{
			return false;
		}

		$queryHash = $this->getSearchQueryHash($searchType, $searchQuery, $constraints, $order, $groupByDiscussion);

		return $this->_getDb()->fetchRow('
			SELECT *
			FROM xf_search
			WHERE query_hash = ?
				AND search_type = ?
				AND search_query = ?
				AND user_id = ?
				AND search_date > ?
			ORDER BY search_date DESC
			LIMIT 1
		', array($queryHash, $searchType, $searchQuery, $userId, XenForo_Application::$time - 3600));
	}

	/**
	 * Generates the search query hash for the given criteria.
	 *
	 * @param string $searchType The type of the search (usually content type or blank, but could be general string)
	 * @param string $searchQuery Text being queried for
	 * @param array $constraints Search constraints
	 * @param string $order Search order
	 * @param boolean $groupByDiscussion True if results should be folded up into their discussion (or other container)
	 *
	 * @return string Query hash
	 */
	public function getSearchQueryHash($searchType, $searchQuery, array $constraints, $order, $groupByDiscussion)
	{
		$hashSource = array($searchType, $searchQuery, $constraints, $order, $groupByDiscussion ? 1 : 0);
		return md5(serialize($hashSource));
	}

	/**
	 * Prepares a search for display/use.
	 *
	 * @param array $search
	 *
	 * @return array
	 */
	public function prepareSearch(array $search)
	{
		$search['searchConstraints'] = $this->_decodeSearchTableData($search['search_constraints'], false);
		$search['searchWarnings'] = $this->_decodeSearchTableData($search['warnings'], false);

		$search['users'] = $this->getModelFromCache('XenForo_Model_User')->getUsersByIds(
			explode(',', $search['user_results']),
			array('join' => XenForo_Model_User::FETCH_USER_FULL)
		);

		return $search;
	}

	/**
	 * Backwards compatability for search data, which was serialized up to 1.0.0 RC3
	 * and thereafter json_encoded.
	 *
	 * @param string $data
	 *
	 * @return array
	 */
	protected function _decodeSearchTableData($data, $isSearchResults = true)
	{
		$decoded = json_decode($data, true);

		if ($decoded === null)
		{
			$decoded = unserialize($data);

			if ($isSearchResults)
			{
				foreach ($decoded AS &$result)
				{
					$result = array($result['content_type'], $result['content_id']);
				}
			}
		}

		return $decoded;
	}

	/**
	 * Gets the list of content types that have search handlers.
	 *
	 * @return array Format: [content type] => search_handler_class
	 */
	public function getSearchContentTypes()
	{
		return $this->getContentTypesWithField('search_handler_class');
	}

	/**
	 * Creates search data handler objects for the specified content types.
	 *
	 * @param array|null $handlerContentTypes List of content types. If null, get all
	 *
	 * @return array Format: [content type] => XenForo_Search_DataHandler_Abstract object
	 */
	public function getSearchDataHandlers(array $handlerContentTypes = null)
	{
		$contentTypes = $this->getSearchContentTypes();
		$handlers = array();
		if ($handlerContentTypes === null)
		{
			$handlerContentTypes = array_keys($contentTypes);
		}

		foreach ($handlerContentTypes AS $contentType)
		{
			if (isset($contentTypes[$contentType]))
			{
				if (!class_exists($contentTypes[$contentType]))
				{
					continue;
				}

				$handlers[$contentType] = XenForo_Search_DataHandler_Abstract::create($contentTypes[$contentType]);
			}
		}

		return $handlers;
	}

	/**
	 * Gets the search data handler for a specific content type.
	 *
	 * @param string $contentType
	 *
	 * @return XenForo_Search_DataHandler_Abstract|false
	 */
	public function getSearchDataHandler($contentType)
	{
		$handlers = $this->getSearchDataHandlers(array($contentType));
		return reset($handlers);
	}

	/**
	 * Groups search results by the content type they belong to.
	 *
	 * @param array $results Format: [] => array(content type, content id)
	 *
	 * @return array Format: [content type][content id] => content id
	 */
	public function groupSearchResultsByType(array $results)
	{
		$resultsGrouped = array();
		foreach ($results AS $result)
		{
			$resultsGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]] = $result[self::CONTENT_ID];
		}

		return $resultsGrouped;
	}

	/**
	 * Gets the data for the search results that are actually viewable. If no
	 * data is returned, the result is not viewable and should be hidden.
	 *
	 * @param array $resultsGrouped Search results, grouped by type (see {@link groupSearchResultsByType()})
	 * @param array $handlers Search data handler objects for all necessary content types
	 * @param boolean $prepareData True if the data should be prepared as well
	 * @param array|null $viewingUser Information about the viewing user (keys: user_id, permission_combination_id, permissions) or null for visitor
	 *
	 * @return array Result data grouped, format: [content type][content id] => data
	 */
	public function getViewableSearchResultData(array $resultsGrouped, array $handlers, $prepareData = true, array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		$dataGrouped = array();
		foreach ($handlers AS $contentType => $handler)
		{
			if (!isset($resultsGrouped[$contentType]))
			{
				continue;
			}

			$dataResults = $handler->getDataForResults($resultsGrouped[$contentType], $viewingUser, $resultsGrouped);
			foreach ($dataResults AS $dataId => $data)
			{
				if (!$handler->canViewResult($data, $viewingUser))
				{
					unset($dataResults[$dataId]);
					continue;
				}

				if ($prepareData)
				{
					$dataResults[$dataId] = $handler->prepareResult($data, $viewingUser);
				}
			}

			$dataGrouped[$contentType] = $dataResults;
		}

		return $dataGrouped;
	}

	/**
	 * Filters a list of search results to those that are viewable.
	 *
	 * @param array $results Search results ([] => array(content type, content id)
	 * @param array|null $viewingUser Information about the viewing user (keys: user_id, permission_combination_id, permissions) or null for visitor
	 *
	 * @return array Same as input results, but unviewable entries removed
	 */
	public function getViewableSearchResults(array $results, array $viewingUser = null)
	{
		$resultsGrouped = $this->groupSearchResultsByType($results);
		$handlers = $this->getSearchDataHandlers(array_keys($resultsGrouped));

		$dataGrouped = $this->getViewableSearchResultData($resultsGrouped, $handlers, false, $viewingUser);

		foreach ($results AS $resultId => $result)
		{
			if (!isset($dataGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]]))
			{
				unset($results[$resultId]);
			}
		}

		return $results;
	}

	/**
	 * Gets the search results ready for display (using the handlers).
	 * The results (in the returned "results" key) have extra, type-specific data
	 * included with them.
	 *
	 * @param array $results Search results ([] => array(content type, content id)
	 * @param array|null $viewingUser Information about the viewing user (keys: user_id, permission_combination_id, permissions) or null for visitor
	 *
	 * @return array Keys: results, handlers
	 */
	public function getSearchResultsForDisplay(array $results, array $viewingUser = null)
	{
		$resultsGrouped = $this->groupSearchResultsByType($results);
		$handlers = $this->getSearchDataHandlers(array_keys($resultsGrouped));

		$dataGrouped = $this->getViewableSearchResultData($resultsGrouped, $handlers, true, $viewingUser);

		foreach ($results AS $resultId => $result)
		{
			if (isset($dataGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]]))
			{
				$results[$resultId]['content'] = $dataGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]];
			}
			else
			{
				unset($results[$resultId]);
			}
		}

		if (!$results)
		{
			return false;
		}

		return array(
			'results' => $results,
			'handlers' => $handlers
		);
	}

	/**
	 * Returns the slice of search results for the requested page.
	 *
	 * @param array $search Search, containing search results
	 * @param integer $page
	 * @param integer $perPage
	 *
	 * @return array Results for the specified page
	 */
	public function sliceSearchResultsToPage(array $search, $page, $perPage)
	{
		if ($page < 1)
		{
			$page = 1;
		}

		if (!isset($search['searchResults']))
		{
			$search['searchResults'] = $this->_decodeSearchTableData($search['search_results'], true);
		}

		return array_slice($search['searchResults'], ($page - 1) * $perPage, $perPage);
	}

	/**
	 * Gets the general search constraints from an array of input.
	 *
	 * @param array $input
	 * @param mixed $errors Returns a list of errors that occurred when getting constraints
	 *
	 * @return array Constraints
	 */
	public function getGeneralConstraintsFromInput(array $input, &$errors = null)
	{
		$constraints = array();
		$errors = array();

		if (!empty($input['date']))
		{
			$constraints['date'] = $input['date'];
		}
		if (!empty($input['title_only']))
		{
			$constraints['title_only'] = $input['title_only'];
		}
		if (!empty($input['nodes']) && reset($input['nodes']))
		{
			if (!empty($input['child_nodes']))
			{
				$childNodeIds = array_keys($this->getModelFromCache('XenForo_Model_Node')->getChildNodesForNodeIds($input['nodes']));
				$input['nodes'] = array_unique(array_merge($input['nodes'], $childNodeIds));
			}
			$constraints['node'] = implode(' ', $input['nodes']);
			if (!$constraints['node'])
			{
				unset($constraints['node']); // just 0
			}
		}
		if (!empty($input['users']))
		{
			/* @var $userModel XenForo_Model_User */
			$userModel = $this->getModelFromCache('XenForo_Model_User');
			$usernames = explode(',', $input['users']);
			$users = $userModel->getUsersByNames($usernames, array(), $notFound);

			if ($notFound)
			{
				$errors[] = new XenForo_Phrase('following_members_not_found_x', array('members' => implode(', ', $notFound)));
			}

			$constraints['user'] = array_keys($users);

			if ($constraints['user'] && !empty($input['user_content']))
			{
				$constraints['user_content'] = $input['user_content'];
			}
		}

		return $constraints;
	}
}