<?php

/**
 * THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
 * WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
 *
 * @package fusionLib
 * @copyright Copyright (c) 2008 - 2016 fusionLib. All rights reserved.
 * @link http://fusionlib.com
 */

/**
 * flSearch Class
 *
 * Class to search an index and return the results.
 *
 * @package fusionLib
 */
class flSearch {

	/**
	 * The database object to use.
	 *
	 * @var string
	 */
	protected $db;

	/**
	 * The base name for database tables.
	 *
	 * @var string
	 */
	protected $tableBase;

	/**
	 * Minimum length of an indexed word.
	 *
	 * @var int
	 */
	protected $minWordLen;

	/**
	 * Maximum length of an indexed word.
	 *
	 * @var int
	 */
	protected $maxWordLen;

	/**
	 * Array of stop words
	 *
	 * @var array
	 */
	protected $stopWords;

	/**
	 * The language to load the stemmer in.
	 *
	 * @var flTextStemmer
	 */
	protected $stemmer;

	/**
	 * The document storage.
	 *
	 * @var flDbNestedSet
	 */
	protected $docs;

	/**
	 * Construct the object.
	 *
	 * @param flDb $db The database object to use.
	 * @param string $tableBase The base name for database tables.
	 */
	function __construct($db, $tableBase) {
		$this->db = $db;
		$this->tableBase = $tableBase;

		// Load config
		$cfg = array();
		$result = $this->db->query("SELECT keyname,value FROM {$this->tableBase}_config WHERE keyname IN('minWordLen', 'maxWordLen', 'stemmer', 'stopWords')");
		while($row = $result->fetch())
			$cfg[$row['keyname']] = $row['value'];

		$this->minWordLen = $cfg['minWordLen'];
		$this->maxWordLen = $cfg['maxWordLen'];
		$this->stemmer = flTextStemmer::load($cfg['stemmer']);
		$this->stopWords = unserialize($cfg['stopWords']);

		// The document set
		$this->docs = new flDbNestedSet($db, "{$this->tableBase}_docs");
	}

	/**
	 * Remove stop words from the passed in array of words.
	 *
	 * @param array $words Array of lowercase words to remove the stop words from.
	 * @return array The cleaned array of words.
	 */
	function removeStopWords($words) {

		// Remove short words and stop words from the input
		$w2 = array();
		foreach($words as $w) {
			if(strlen($w) >= $this->minWordLen && strlen($w) <= $this->maxWordLen && !isset($this->stopWords[$w]))
				$w2[] = $w;
		}

		return $w2;
	}

	/**
	 * Search the index.
	 *
	 * @param string $query The query string, max 100 characters.
	 * @param array|string $path Array of category names that is the path to limit the search to, '' to search all.
	 * @param bool $shortPath True if using short path format root:node.
	 * @param bool $returnShortPaths true if returning only root and node id per result rather than full path to result node.
	 * @param array $accessPerms Array of acces permissions that the searcher has.
	 * @param array $inFields The list of fields to look within or empty for all fields.
	 * @param int $start The offset into the results.
	 * @param int $count The number of results to return.
	 * @param bool $matchAll true if all terms must be present in a document or false for best match.
	 * @return flSearchResults Object holding the results.
	 */
	function find($query, $path = '', $shortPath = true, $returnShortPaths = true, $accessPerms = array(), $inFields = array(), $start = 0, $count = 10, $matchAll = false) {

		// Extract path if present in query
		$qPath = array();
		if(preg_match('/\bpath:"([^"]+)"/i', $query, $qPath)) {
			$query = str_replace($qPath[0], '', $query);
			$path = $qPath[1];
			$shortPath = false;
		}

		// Extract sort order
		$sortOrder = array();
		if(preg_match('/\bsort:"([a-z0-9_\.-]+):(asc|desc)"/i', $query, $sortOrder)) {
			$query = str_replace($sortOrder[0], '', $query);
		}

		// Extract ranges from the query
		$ranges = array();
		preg_match_all('/\b(([a-z0-9_\.-]+):\[\s*(-?[\d\.]+)?\s*(to|is)\s*(-?[\d\.]+)?\])/i', $query, $ranges, PREG_SET_ORDER);

		// Build number range queries
		$numbers = array();
		$ints = array();
		$numFields = array();

		$result = $this->db->query("SELECT fldname, fldid, fldtype FROM {$this->tableBase}_fields WHERE fldtype!='text'");
		while($row = $result->fetch())
			$numFields[$row['fldname']] = $row;

		foreach ($ranges as $range) {
			$query = str_replace($range[0], '', $query);

			$q = '';
			if($range[4] == 'to') {
				if(!isset($range[5]))
					$q = 'val >= ' . $this->db->escape($range[3]);
				else if(empty($range[3]))
					$q = 'val <= ' . $this->db->escape($range[5]);
				else
					$q = 'val BETWEEN ' . $this->db->escape($range[3]) . ' AND ' . $this->db->escape($range[5]);
			}
			else if($range[4] == 'is') {
				$q = 'val=' . $this->db->escape($range[5]);
			}

			if(isset($numFields[$range[2]])) {
				switch($numFields[$range[2]]['fldtype']) {
					case 'int':
						$ints[$numFields[$range[2]]['fldid']] = $q;
						break;

					case 'number':
						$numbers[$numFields[$range[2]]['fldid']] = $q;
						break;
				}
			}
		}

		// Convert the parts of the query into statements
		$intsQ = array();
		$numsQ = array();
		foreach($ints as $f => $q) {
			$intsQ[] = " && d.id IN(SELECT DISTINCT id FROM {$this->tableBase}_ints WHERE fldid=$f AND $q)";
		}
		foreach($numbers as $f => $q) {
			$numsQ[] = " && d.id IN(SELECT DISTINCT id FROM {$this->tableBase}_nums WHERE fldid=$f AND $q)";
		}

		// If query too long abort
		if(flUtf8::strlen($query) > 100)
			return new flSearchResults();

		// Get the start document and limits
		if((is_string($path) && $path == '') || !count($path)) {
			$root = $lft = $rgt = 0;
			$totalDocs = $this->db->fetchOne("SELECT COUNT(*) FROM {$this->tableBase}_docs");
		}
		else {
			if($shortPath) {
				if(!($d = $this->docs->getByShortPath($path, 'slug', array('id', 'lft', 'rgt'))))
					return new flSearchResults();
			}
			else {
				if(!($d = $this->docs->getByPath($path, 'slug', array('id', 'lft', 'rgt'))))
					return new flSearchResults();
			}

			$root = $d['id'];
			$lft = $d['lft'];
			$rgt = $d['rgt'];
			$totalDocs = (int)(($rgt - $lft) / 2) + 1;
		}

		// If no documents
		if(!$totalDocs)
			return new flSearchResults();

		// Convert field names to ids
		if(count($inFields)) {
			$result = $this->db->query("SELECT fldid FROM {$this->tableBase}_fields WHERE fldname IN(" . str_repeat('?,', count($inFields) - 1) . "?) AND fldtype='text'", $inFields);

			$inFields = array();
			while($row = $result->fetch())
				$inFields[] = $row['fldid'];

			// Abort if can't convert
			if(!count($inFields))
				return new flSearchResults();
		}

		// Get the words form the query text
		$words = explode(' ', flUtf8::toLower(flTextStrip::tags($query)));

		$pw = array();
		$nw = array();
		foreach($words as $w) {
			$sw = flTextStrip::symbols(flTextStrip::punctuation($w));
			if(!empty($sw)) {
				if($w[0] == '-')
					$nw[] = $sw;
				else
					$pw[] = $sw;
			}
		}

		// Remove the stop words
		$pw = $this->removeStopWords($pw);
		$nw = $this->removeStopWords($nw);

		// Stem the words and remove duplicates
		$pw = array_unique($this->stemmer->stemWords($pw));
		$nw = array_unique($this->stemmer->stemWords($nw));

		// Remove words from search that also must not be in the document
		$pw = array_diff($pw, $nw);

		// Save number of terms, no terms to find abort
		if(!($qterms = count($pw)) && !count($ranges))
			return new flSearchResults();

		// Build the sort order query
		$orderBy = $qterms ? 't DESC,weight DESC' : 'publishedat DESC';
		$orderByTable = $orderByWhere = '';
		if(count($sortOrder)) {
			switch($sortOrder[1]) {
				case 'date_added':
					$orderBy = 'publishedat ' . strtoupper($sortOrder[2]);
					break;

				default:
					if(isset($numFields[$sortOrder[1]])) {
						switch($numFields[$sortOrder[1]]['fldtype']) {
							case 'number':
								$orderByTable = ",{$this->tableBase}_nums AS so";
								$orderByWhere = 'd.id=so.id && so.fldid=' . $numFields[$sortOrder[1]]['fldid'] . ' && ';
								$orderBy = 'so.val ' . strtoupper($sortOrder[2]);
								break;

							case 'int':
								$orderByTable = ",{$this->tableBase}_ints AS so";
								$orderByWhere = 'd.id=so.id && so.fldid=' . $numFields[$sortOrder[1]]['fldid'] . ' && ';
								$orderBy = 'so.val ' . strtoupper($sortOrder[2]);
								break;
						}
					}
					break;
			}
		}

		if($qterms) {
			// Get the word ids and calculate the idf of each, unknown words are dropped
			$idf = 'CASE i.wordid';
			$terms = array();

			$q = "SELECT {$this->tableBase}_words.wordid, COUNT(*) AS df FROM {$this->tableBase}_words,{$this->tableBase}_index";

			// Limiting document branch
			if ($root)
				$q .= ",{$this->tableBase}_docs";

			$q .= ' WHERE word IN(' . str_repeat('?,', count($pw) - 1) . "?) && {$this->tableBase}_words.wordid={$this->tableBase}_index.wordid";

			// If limiting to named fields
			if (count($inFields))
				$q .= ' && fldid IN(' . implode(',', $inFields) . ')';

			// Limiting document branch
			if ($root)
				$q .= " && {$this->tableBase}_index.id={$this->tableBase}_docs.id && lft BETWEEN $lft AND $rgt";

			$q .= " GROUP BY {$this->tableBase}_words.wordid";

			$result = $this->db->query($q, $pw);
			while ($row = $result->fetch()) {
				$terms[] = $row['wordid'];
				$idf .= " WHEN $row[wordid] THEN " . pow(1.0 + log($totalDocs / (float)($row['df'] + 1)), 2);
			}
			$idf .= ' END';

			// Get the number of search terms present in document set and abort if none found or matchAll and not required number
			$tc = count($terms);
			if (!$tc || ($matchAll && $qterms != $tc))
				return new flSearchResults();

			// Get the id's of words that must not appear
			$notterms = array();
			if (count($nw)) {
				$result = $this->db->query("SELECT wordid FROM {$this->tableBase}_words WHERE word IN(" . str_repeat('?,', count($nw) - 1) . '?)', $nw);
				while ($row = $result->fetch())
					$notterms[] = $row['wordid'];
			}

			// Build the query
			$q = "SELECT SQL_CALC_FOUND_ROWS d.id, COUNT(*) AS t, (COUNT(*) / $qterms) * SUM(SQRT(i.tind) * $idf * norm) AS weight FROM {$this->tableBase}_docs d{$orderByTable}, (SELECT i.id,i.wordid,SUM(i.tind) AS tind FROM {$this->tableBase}_index AS i ";

			// If terms that must not be present are available
			if (count($notterms)) {
				$q .= "LEFT JOIN {$this->tableBase}_index AS i2 ON i.id=i2.id && i2.wordid IN(" . implode(',', $notterms) . ')';
				if (count($inFields))
					$q .= ' && i2.fldid IN(' . implode(',', $inFields) . ')';

				if ($root)
					$q .= " LEFT JOIN {$this->tableBase}_docs AS d2 ON i.id=d2.id";
				$q .= " WHERE i2.wordid IS NULL &&";
				if ($root)
					$q .= " lft BETWEEN $lft AND $rgt &&";
			} else
				$q .= "WHERE";

			$q .= " i.wordid IN(" . implode(',', $terms) . ")";

			// If limiting to named fields
			if (count($inFields))
				$q .= ' && i.fldid IN(' . implode(',', $inFields) . ')';

			$q .= ' GROUP BY i.id,i.wordid) AS i ';

			$q .= "WHERE {$orderByWhere} d.id=i.id";

			// If limiting to a path
			if($root)
				$q .= " && lft BETWEEN $lft AND $rgt";
		}
		else {
			// Build query for ranges only
			$q = "SELECT SQL_CALC_FOUND_ROWS d.id FROM {$this->tableBase}_docs AS d{$orderByTable} WHERE $orderByWhere ";

			// If limiting to a path
			if($root)
				$q .= " lft BETWEEN $lft AND $rgt &&";
		}

		// Validate access permissions
		$q .= ($qterms ? ' &&' : '') . " NOT (SELECT COUNT(*) FROM {$this->tableBase}_docs AS c,{$this->tableBase}_docs AS p WHERE c.id=d.id && MBRWithin(Point(0, c.lft), p.lftrgtset) && p.accessperm NOT IN (''" . str_repeat(',?', count($accessPerms)) . '))';

		// Add number and int ranges
		if(count($intsQ)) {
			$q .= join('', $intsQ);
		}
		if(count($numsQ)) {
			$q .= join('', $numsQ);
		}

		$q .= ' GROUP BY d.id';

		if($qterms) {
			// If all terms must be present
			if ($matchAll)
				$q .= " HAVING t=$qterms";

			$q .= " ORDER BY $orderBy LIMIT $start,$count";
		}
		else {
			$q = str_replace('WHERE &&', 'WHERE', $q);
			$q .= " ORDER BY $orderBy LIMIT $start,$count";
		}

		// Run query
		$docs = array();
		$result = $this->db->query($q, $accessPerms);
		$totalMatches = $this->db->fetchOne('SELECT FOUND_ROWS()');
		while($row = $result->fetch()) {
			// Get the document path
			if($returnShortPaths)
				$docs[] = $this->docs->getShortPath($row['id'], 'slug');
			else {
				$res = $this->docs->getPath($row['id'], array('slug'));
				$segs = array();
				foreach($res as $d)
					$segs[] = $d['slug'];
				$docs[] = $segs;
			}
		}

		// Return the search results
		return new flSearchResults($docs, $totalMatches);
	}
}
