<?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
 */

/**
 * flSearchIndexer Class
 *
 * Class to index and manage search data.
 *
 * @package fusionLib
 */
class flSearchIndexer extends flSearch {

	/**
	 * List of field names to ids.
	 *
	 * @var array
	 */
	protected $fieldCache = null;

	/**
	 * List of the field names to types.
	 *
	 * @var array
	 */
	protected $fieldTypeCache = null;

	/**
	 * Create a missing path.
	 *
	 * @param string|array $path The path to create.
	 * @return int The ID of the last node added or false.
	 */
	protected function createPath($path) {
		if(!is_array($path)) $path = explode('/', $path);

		if(!($d = $this->docs->getByPath($path, 'slug', array('id')))) {
			$parent = 0;
			$l = count($path);
			for($i=0;$i<$l;$i++) {
				// Lookup node
				if(!($id = $this->db->fetchOne("SELECT id FROM {$this->tableBase}_docs WHERE parentid=? && slug=?", $parent, $path[$i]))) {
					// Insert a new node
					if(!($id = $this->docs->insertNode($parent, flDbNestedSet::UNDER, array('norm' => 0, 'slug' => $path[$i], 'accessperm' => '', 'publishedat' => null))))
						return false;
				}
				$parent = $id;
			}
		}
		else
			$parent = $d['id'];

		return $parent;
	}


	/**
	 * Add a new document to the given path.
	 *
	 * @param string|array $path The location to add the new document in.
	 * @param string $name The name of the document to add.
	 * @param string $accessPerm The access permission for the document.
	 * @param string $publishedAt The time the document was published.
	 * @return int The ID of the new document or false if failed.
	 */
	function addDocument($path, $name, $accessPerm = '', $publishedAt = null) {
		// Lookup parent node
		if(empty($path))
			$parent = '0';
		else if(!($parent = $this->createPath($path)))
			return false;

		// If the document exists then update the access perm
		if($id = $this->db->fetchOne("SELECT id FROM {$this->tableBase}_docs WHERE parentid=? && slug=?", $parent, $name)) {
			$this->db->update("{$this->tableBase}_docs", array('accessperm' => $accessPerm, 'publishedat' => $publishedAt), 'id=?', $id);
			return $id;
		}
		// Else add the document
		else
			return $this->docs->insertNode($parent, flDbNestedSet::UNDER, array('norm' => 0, 'slug' => $name, 'accessperm' => $accessPerm, 'publishedat' => $publishedAt));
	}

	/**
	 * Delete the document and all child documents.
	 *
	 * @param string|array $path The page of the document to delete.
	 * @return bool true if the document was deleted; else false.
	 */
	function deleteDocument($path) {
		// Get the ID of the document
		if(!($d = $this->docs->getByPath($path, 'slug', array('id'))))
			return false;

		// Delete the doc and its children
		$this->docs->deleteNode($d['id']);

		// Delete the indexed words and tags for the deleted document(s)
		$this->db->query("DELETE f FROM {$this->tableBase}_index AS f LEFT JOIN {$this->tableBase}_docs AS c ON(c.id = f.id) WHERE c.id IS NULL");
		$this->db->query("DELETE f FROM {$this->tableBase}_tags AS f LEFT JOIN {$this->tableBase}_docs AS c ON(c.id = f.id) WHERE c.id IS NULL");
		$this->db->query("DELETE f FROM {$this->tableBase}_ints AS f LEFT JOIN {$this->tableBase}_docs AS c ON(c.id = f.id) WHERE c.id IS NULL");
		$this->db->query("DELETE f FROM {$this->tableBase}_nums AS f LEFT JOIN {$this->tableBase}_docs AS c ON(c.id = f.id) WHERE c.id IS NULL");

		return true;
	}

	/**
	 * Move a document to a new location in the tree.
	 *
	 * @param string|array $src The source path including the document to move.
	 * @param string|array $dst The destination path to move the document into.
	 * @return bool true if the document was moved; else false.
	 */
	function moveDocument($src, $dst) {
		// Get the source document id
		if(!($s = $this->docs->getByPath($src, 'slug', array('id'))) || !($d = $this->createPath($dst)))
			return false;

		// Move the document
		return $this->docs->moveNode($s['id'], $d, flDbNestedSet::UNDER);
	}

	/**
	 * Rename a document.
	 *
	 * @param string|array $path The path to the document to rename.
	 * @param string $name The new name of the document.
	 * @return bool true if the document was renamed; else false.
	 */
	function renameDocument($path, $name) {
		if(!($s = $this->docs->getByPath($path, 'slug', array('id'))))
			return false;
		return $this->db->update("{$this->tableBase}_docs", array('slug' => $name), 'id=?', $s['id']);
	}

	/**
	 * Set the access permission of a document.
	 *
	 * @param string|array $path The path to the document to rename.
	 * @param string $accessPerm The access permission.
	 * @return bool true if the document was updated; else false.
	 */
	function setDocumentAccess($path, $accessPerm) {
		if(!($s = $this->docs->getByPath($path, 'slug', array('id'))))
			return false;
		return $this->db->update("{$this->tableBase}_docs", array('accessperm' => $accessPerm), 'id=?', $s['id']);
	}

	/**
	 * Set the published at time of a document.
	 *
	 * @param string|array $path The path to the document to rename.
	 * @param string $publishedAt The time the document was published.
	 * @return bool true if the document was updated; else false.
	 */
	function setDocumentPublishedAt($path, $publishedAt) {
		if(!($s = $this->docs->getByPath($path, 'slug', array('id'))))
			return false;
		return $this->db->update("{$this->tableBase}_docs", array('publishedat' => $publishedAt), 'id=?', $s['id']);
	}

	/**
	 * Drop index for the document.
	 *
	 * @param string|array $path Path to the document to drop the index of.
	 */
	function dropDocumentIndex($path) {
		if(!($s = $this->docs->getByPath($path, 'slug', array('id'))))
			return;
		$this->db->delete("{$this->tableBase}_index", 'id=?', $s['id']);
		$this->db->delete("{$this->tableBase}_ints", 'id=?', $s['id']);
		$this->db->delete("{$this->tableBase}_nums", 'id=?', $s['id']);
	}

	/**
	 *
	 * Drop tags for the document.
	 *
	 * @param string|array $path Path to the document to drop the tags of.
	 */
	function dropDocumentTags($path) {
		if(!($s = $this->docs->getByPath($path, 'slug', array('id'))))
			return;
		$this->db->delete("{$this->tableBase}_tags", 'id=?', $s['id']);
	}

	/**
	 * Clears all index and word data from the database.
	 *
	 * @param bool $clearDocs If true documents are cleared; false to just clear words, tags and index.
	 */
	function clearIndex($clearDocs = false) {
		$this->db->query("TRUNCATE TABLE {$this->tableBase}_index");
		$this->db->query("TRUNCATE TABLE {$this->tableBase}_words");
		$this->db->query("TRUNCATE TABLE {$this->tableBase}_tags");
		$this->db->query("TRUNCATE TABLE {$this->tableBase}_ints");
		$this->db->query("TRUNCATE TABLE {$this->tableBase}_nums");
		if($clearDocs)
			$this->db->query("TRUNCATE TABLE {$this->tableBase}_docs");
	}

	/**
	 * Index the document text.
	 *
	 * Unknown fields are ignored.
	 *
	 * @param string|array $path The path to the document being indexed.
	 * @param array $text Array of fields holding the raw UTF-8 HTML content to index.
	 * @param string $accessPerm The access permission for the document.
	 * @param float $docBoost Boost value to apply to the document to artificially increase it's rank in results.
	 * @return bool true if the document was indexed else false.
	 */
	function indexDocument($path, $text, $accessPerm = '', $docBoost = 1.0) {
		if(!is_array($path)) $path = explode('/', $path);

		// Get the document id
		$name = array_pop($path);
		if(!($docid = $this->addDocument($path, $name, $accessPerm)))
			return false;

		// Get the fields
		if($this->fieldCache === null) {
			$this->fieldCache = array();
			$this->fieldTypeCache = array();
			$result = $this->db->query("SELECT fldname,fldid,fldtype FROM {$this->tableBase}_fields");
			while($row = $result->fetch()) {
				$this->fieldCache[$row['fldname']] = $row['fldid'];
				$this->fieldTypeCache[$row['fldname']] = $row['fldtype'];
			}
		}

		// For all fields get the words
		$fields = array();
		$fieldsInts = array();
		$fieldsNums = array();
		$termCount = 0;
		foreach($text as $fname => $ftxt) {

			// If field present
			if(isset($this->fieldCache[$fname])) {
				switch($this->fieldTypeCache[$fname]) {
					// Build term frequency for text fields
					case 'text':

						// Get the words from the field
						$words = flTextStrip::symbols(flTextStrip::punctuation(flTextStrip::tags($ftxt)));
						$words = strlen($words) ? explode(' ', flUtf8::toLower($words)) : array();
						$termCount += count($words);

						// Remove the stop words and stem
						$words = $this->stemmer->stemWords($this->removeStopWords($words));

						// Count words
						$tf = array();
						foreach ($words as $w)
							$tf[$w] = isset($tf[$w]) ? $tf[$w] + 1 : 1;

						// Save the term frequency for the field
						$fields[$this->fieldCache[$fname]] = $tf;

						break;

					// Build int fields
					case 'int':
						$fieldsInts[$this->fieldCache[$fname]] = (int)$ftxt;
						break;

					// Build num fields
					case 'number':
						$fieldsNums[$this->fieldCache[$fname]] = (float)$ftxt;
						break;

				}
			}
		}

		// Calculate the norm (assume document boost = 1.0)
		$norm = $termCount ? $docBoost * (1.0 / sqrt($termCount)) : 0;

		// Dump the existing index for the document
		$this->db->query("DELETE FROM {$this->tableBase}_index WHERE id=?", $docid);
		$this->db->query("DELETE FROM {$this->tableBase}_ints WHERE id=?", $docid);
		$this->db->query("DELETE FROM {$this->tableBase}_nums WHERE id=?", $docid);

		// Update the document normal
		$this->db->update("{$this->tableBase}_docs", array('norm' => $norm), 'id=?', $docid);

		// Build the index
		foreach($fields as $fid => $tf) {
			foreach($tf as $w => $v) {
				do {
					// Look up the id of the stemmed word, if not found add
					if(($wid = $this->db->fetchOne("SELECT wordid FROM {$this->tableBase}_words WHERE word=?", $w)) === false) {

						// Look for an unused word, if not found add word
						if(!($d = $this->db->fetchRow("SELECT wordid,word FROM {$this->tableBase}_words LEFT JOIN {$this->tableBase}_index USING(wordid) WHERE fldid IS NULL LIMIT 1")))
							$this->db->insert("{$this->tableBase}_words", array('word' => $w));
						// Reuse word
						else
							$this->db->update("{$this->tableBase}_words", array('word' => $w), 'wordid=? && word=?', $d['wordid'], $d['word']);
					}
				} while(!$wid);

				// Add the word to the document index
				$this->db->insert("{$this->tableBase}_index", array('wordid' => $wid, 'fldid' => $fid, 'id' => $docid, 'tind' => min($v, 65535)));
			}
		}

		// Write ints
		foreach($fieldsInts as $fid => $v) {
			$this->db->insert("{$this->tableBase}_ints", array('fldid' => $fid, 'id' => $docid, 'val' => $v));
		}

		// Write numbers
		foreach($fieldsNums as $fid => $v) {
			$this->db->insert("{$this->tableBase}_nums", array('fldid' => $fid, 'id' => $docid, 'val' => $v));
		}

		return true;
	}

	/**
	 * Record document tags.
	 *
	 * @param string|array $path The path to the document being indexed.
	 * @param string|array $tags Array of tags or string of comma separated tags.
	 * @param string $accessPerm The access permission for the document.
	 * @return bool true if the document was tagged else false.
	 */
	function tagDocument($path, $tags, $accessPerm = '') {
		if(!is_array($path)) $path = explode('/', $path);

		// Convert string of tags to array
		if(!is_array($tags)) {
			$tlist = explode(',', $tags);
			$tags = array();
			foreach($tlist as $t) {
				$t = trim($t);
				if(!empty($t))
					$tags[] = $t;
			}
		}

		// Get the document id
		$name = array_pop($path);
		if(!($docid = $this->addDocument($path, $name, $accessPerm)))
			return false;

		// Dump the existing tags for the document
		$this->db->query("DELETE FROM {$this->tableBase}_tags WHERE id=?", $docid);

		// Add the tags into the database
		foreach($tags as $tag)
			$this->db->insert("{$this->tableBase}_tags", array('tag' => flUtf8::toLower($tag), 'id' => $docid));

		return true;
	}
}
