<?php
/**
 * Copyright (C) 2025 fhcomplete.org
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

if (! defined('BASEPATH')) exit('No direct script access allowed');

use \stdClass as stdClass;

/**
 * This is a alternative for SearchBarLib for advanced searches
 */
class SearchLib
{
	// Error constats
	const ERROR_WRONG_JSON = 'ERR001';
	const ERROR_WRONG_SEARCHSTR = 'ERR002';
	const ERROR_NO_TYPES = 'ERR003';
	const ERROR_WRONG_TYPES = 'ERR004';
	const ERROR_NOT_AUTH = 'ERR005';

	private $_ci; // Code igniter instance

	private $_searchfunction_priorities = [];
	private $_numeric_searchfunctions = [];
	private $_allowed_searchfunctions = [];
	
	/**
	 * Gets the CI instance and loads model
	 *
	 * @param array		$params
	 * @return void
	 */
	public function __construct($params = null)
	{
		$this->_ci =& get_instance(); // get code igniter instance

		$config = $params['config'] ?? null;
		// It is loaded only to have the DB functions available
		$this->_ci->load->model('person/Benutzer_model', 'BenutzerModel');

		// Load Config
		$this->_ci->load->config('search', true, (boolean)$config);
		$this->_ci->load->config('searchfunctions', true);
		if ($config) {
			$this->_ci->load->config($config, true);
			$this->_ci->config->set_item('search', $this->_ci->config->item($config));
		}

		$this->_ci->load->library('PhrasesLib', [['search'], null], 'search_phrases');

		// Precompute helper arrays
		foreach ($this->_ci->config->item('searchfunctions') as $key => $arr) {
			$this->_searchfunction_priorities[$key] = $arr['priority'];
			if ($arr['force_integer'] ?? false)
				$this->_numeric_searchfunctions[] = $key;
			$this->_allowed_searchfunctions[] = $key;
		}
	}

	//------------------------------------------------------------------------------------------------------------------
	// Public methods

	/**
	 * It performes the search of the given search string using the specified search types
	 *
	 * @param string							$searchstring
	 * @param array								$types (optional)
	 *
	 * @return stdClass		containing an array with the result on index 0
	 * 						and the overall query time on index 1.
	 */
	public function search($searchstring, $types = [])
	{
		if (!$types) {
			$types = $this->_ci->config->item('search');
		} else {
			$tmp = [];
			$missing = [];
			foreach ($types as $type) {
				$typeconfig = $this->_ci->config->item($type, 'search');
				if (!$typeconfig) {
					$missing[] = $type;
				} else {
					$tmp[$type] = $typeconfig;
				}
			}
			if ($missing) {
				$p = $this->_ci->search_phrases;
				return error(array_map(function ($type) use ($p) {
					return $p->t('search', 'error_missing_config', [
						'type' => $type
					]);
				}, $missing));
			}
			$types = $tmp;
		}


		// Convert searchstring into array
		list($searchArray, $searchstring) = $this->_convertQuery($searchstring, $types);


		$sql = $this->getDynamicSearchSqls($searchArray, array_keys($types));
		if (isError($sql))
			return $sql;
		if (!hasData($sql)) {
			$retval = success([]);
			$retval->meta = ['time' => 0, 'searchstring' => $searchstring];
			return $retval;
		}

		$msc = microtime(true);
		$result = $this->_ci->BenutzerModel->execReadOnlyQuery(getData($sql));
		$msc = microtime(true) - $msc;

		if (isError($result))
			return $result;

		$retval = success($result->retval);
		$retval->meta = [
			'time' => $msc,
			'searchstring' => $searchstring
		];

		return $retval;
	}

	/**
	 * Generates the search query for the given search string and the
	 * specified search type.
	 *
	 * @param array								$searchArray
	 * @param string							$table
	 *
	 * @return stdClass		containing the query string.
	 */
	public function getDynamicSearchSql($searchArray, $table)
	{
		$res = $this->checkConfig($table);
		if (isError($res))
			return $res;
		$table_config = getData($res);

		$sql_with = [];
		
		$sql_select = $this->prepareDynamicSearchSql($sql_with, $searchArray, $table);

		if (!$sql_select)
			return success("");

		$lang = getUserLanguage();

		$output = "WITH";
		if ($sql_with && $sql_with[0] === 'RECURSIVE') {
			$output .= " RECURSIVE";
			array_shift($sql_with);
		}

		$output .= "
			lang (index) AS (
				SELECT index
				FROM public.tbl_sprache
				WHERE sprache=" . $this->_ci->db->escape($lang) . "
				LIMIT 1
			),
			auth (uid) AS (
				SELECT " . $this->_ci->db->escape(getAuthUID()) . " AS uid
			)";

		if ($sql_with) {
			$sql_with = array_unique($sql_with);
			$output .= ", " . implode(", ", $sql_with);
		}

		$other_selects = "";
		if (isset($table_config['resultfields']))
			$other_selects = implode(", ", $table_config['resultfields']);
		if ($other_selects)
			$other_selects = ", " . $other_selects;

		$output .= "
			, q (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
				SELECT " . $this->_formatPrimarykeys($table_config['primarykey']) . ", MAX(rank)
				FROM (" . implode(" UNION ", $sql_select) . ") q
				GROUP BY " . $this->_formatPrimarykeys($table_config['primarykey']) . "
			)
			SELECT
				" . $this->_ci->db->escape($table) . " AS type,
				q.rank
				" . $other_selects . "
			FROM q
			" . ($table_config['resultjoin'] ?? "") . "
			ORDER BY rank DESC
		";

		return success($output);
	}

	/**
	 * Generates the search query for the given search string and the
	 * specified search types.
	 *
	 * @param array								$searchArray
	 * @param array								$types
	 *
	 * @return stdClass		containing the query string.
	 */
	public function getDynamicSearchSqls($searchArray, $types)
	{
		$with = [];
		$selects = [];
		foreach ($types as $type) {
			$res = $this->checkConfig($type);
			if (isError($res))
				return $res;
			$table_config = getData($res);

			$select = $this->prepareDynamicSearchSql($with, $searchArray, $type);
			if (!$select)
				continue;

			$with[] = "final_" . $type . " (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
				SELECT " . $this->_formatPrimarykeys($table_config['primarykey']) . ", MAX(rank)
				FROM (" . implode(" UNION ", $select) . ") q
				GROUP BY " . $this->_formatPrimarykeys($table_config['primarykey']) . "
			)";

			$renderer = $table_config['renderer'] ?? $type;
			$selects[] = "
				SELECT
					" . $this->_ci->db->escape($renderer) . " AS renderer,
					" . $this->_ci->db->escape($type) . " AS type,
					rank,
					TO_JSONB((SELECT x FROM (SELECT " . implode(", ", $table_config['resultfields'] ?? ['*']) . ") x)) AS data
				FROM final_" . $type  . "
				" . ($table_config['resultjoin'] ?? "");
		}

		if (!$selects)
			return success("");

		$recursive = "";
		if ($with && $with[0] === "RECURSIVE") {
			$recursive = "RECURSIVE ";
			array_shift($with);
		}

		$with = array_unique($with);

		$lang = getUserLanguage();
		array_unshift($with, "lang (index) AS (
			SELECT index
			FROM public.tbl_sprache
			WHERE sprache=" . $this->_ci->db->escape($lang) . "
			LIMIT 1
		)");
		array_unshift($with, "auth (uid) AS (
			SELECT " . $this->_ci->db->escape(getAuthUID()) . " AS uid
		)");

		return success("
			WITH " . $recursive . implode(", ", $with) . "
			SELECT *
			FROM (" . implode(" UNION ", $selects) . ") q
			ORDER BY rank DESC
			LIMIT 100
		");
	}

	//------------------------------------------------------------------------------------------------------------------
	// Protected methods

	/**
	 * Check config
	 *
	 * @param string							$name
	 *
	 * @return stdClass
	 */
	protected function checkConfig($name)
	{
		$table_config = $this->_ci->config->item($name, 'search');

		if (!$table_config)
			return error($this->_ci->search_phrases->t('search', 'error_missing_config', [
				'type' => $name
			]));

		$errors = [];
		if (!isset($table_config['table'])
			|| !is_string($table_config['table'])
			|| !$table_config['table']
		) {
			$errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
				'type' => $name,
				'field' => 'table'
			]);
		}
		if (!isset($table_config['primarykey'])
			|| !is_string($table_config['primarykey'])
			|| !$table_config['primarykey']
		) {
			$errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
				'type' => $name,
				'field' => 'primarykey'
			]);
		}
		if (!isset($table_config['resultfields'])
			|| !is_array($table_config['resultfields'])
			|| !$table_config['resultfields']
		) {
			$errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
				'type' => $name,
				'field' => 'resultfields'
			]);
		}
		if (!isset($table_config['searchfields'])
			|| !is_array($table_config['searchfields'])
			|| !$table_config['searchfields']
		) {
			$errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
				'type' => $name,
				'field' => 'searchfields'
			]);
		} else {
			foreach ($table_config['searchfields'] as $searchfield => $config) {
				if (!isset($config['field'])
					|| !is_string($config['field'])
					|| !$config['field']
				) {
					$errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config_searchfield', [
						'type' => $name,
						'searchfield' => $searchfield,
						'field' => 'field'
					]);
				}
				if (!isset($config['comparison'])
					|| !is_string($config['comparison'])
					|| !in_array($config['comparison'], $this->_allowed_searchfunctions)
				) {
					$errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config_searchfield', [
						'type' => $name,
						'searchfield' => $searchfield,
						'field' => 'comparison'
					]);
				}
			}
		}

		if ($errors)
			return error($errors);

		return success($table_config);
	}

	/**
	 * Generates the with statements for the given search string and the
	 * specified search type.
	 *
	 * @param array								&$sqlWith
	 * @param array								$searchArray
	 * @param string							$table
	 *
	 * @return string	a query string or the name of the prepared select.
	 */
	protected function prepareDynamicSearchSql(&$sqlWith, $searchArray, $table)
	{
		$table_config = $this->_ci->config->item($table, 'search');

		$id_offset = count($sqlWith);


		$allowed_codes_w_order = ['' => 0, '!' => -1];
		$max = max($this->_searchfunction_priorities);
		foreach ($table_config['searchfields'] as $code => $config) {
			$allowed_codes_w_order[$code] = $this->_searchfunction_priorities[$config['comparison']];
			$allowed_codes_w_order['!' . $code] = $this->_searchfunction_priorities[$config['comparison']] - $max - 2;
		}

		$check_order = $this->_searchfunction_priorities;
		uasort($table_config['searchfields'], function ($a, $b) use ($check_order) {
			return $check_order[$b['comparison']] - $check_order[$a['comparison']];
		});

		$integer_functions = $this->_numeric_searchfunctions;
		$integer_fields = array_keys(array_filter($table_config['searchfields'], function ($a) use ($integer_functions) {
				return in_array($a['comparison'], $integer_functions);
		}));

		$only_integer_fields = count($integer_fields) == count($table_config['searchfields']);

		$aliases = [];
		foreach ($table_config['searchfields'] as $field => $config) {
			if (isset($config['alias'])) {
				foreach ($config['alias'] as $alias) {
					$aliases[$alias] = $field;
					$aliases['!' . $alias] = '!' . $field;
				}
			}
		}

		$sql_select = [];

		if (isset($table_config['prepare'])) {
			$this->_addPreparesToSqlWith($sqlWith, $table_config['prepare']);
		}

		foreach ($searchArray as $or_search) {
			if (isset($or_search['-filter']) && !in_array($table, $or_search['-filter']))
				continue;
			unset($or_search['-filter']);
			
			foreach ($aliases as $alias => $field) {
				if (isset($or_search[$alias])) {
					$or_search[$field] = array_merge($or_search[$alias], $or_search[$field] ?? []);
					unset($or_search[$alias]);
				}
			}

			// NOTE(chris): early out if not allowed fields are in the search array
			$used_codes = array_keys($or_search);
			if (count(array_intersect($used_codes, array_keys($allowed_codes_w_order))) != count($used_codes))
				continue;

			// NOTE(chris): expand general excludes to all fields
			if (isset($or_search['!'])) {
				$not = $or_search['!'];
				unset($or_search['!']);
				foreach ($table_config['searchfields'] as $code => $config) {
					if (isset($or_search['!' . $code]))
						$or_search['!' . $code] = array_unique(array_merge($or_search['!' . $code], $not));
					else
						$or_search['!' . $code] = $not;
				}
			}

			// NOTE(chris): early out if all searchfields require an integer and at least one searchword is not a number
			if ($only_integer_fields
				&& isset($or_search[""])
				&& $this->_hasAtLeastOneNaN($or_search[""])
			) {
				continue;
			}

			$skip = false;
			foreach ($integer_fields as $code) {
				// NOTE(chris): filter non integer for integer fields
				if (isset($or_search['!' . $code])) {
					$or_search['!' . $code] = array_filter($or_search['!' . $code], function ($a) {
						return is_numeric($a);
					});
					if (!$or_search['!' . $code])
						unset($or_search['!' . $code]);
				}
				// NOTE(chris): early out if a searchword that is not a number is compared to a searchfield that requires an integer
				if (isset($or_search[$code])
					&& $this->_hasAtLeastOneNaN($or_search[$code])
				) {
					$skip = true;
					break;
				}
			}
			if ($skip)
				continue;

			// NOTE(chris): sort for performance reasons
			uksort($or_search, function ($a, $b) use ($allowed_codes_w_order) {
				return $allowed_codes_w_order[$b] - $allowed_codes_w_order[$a];
			});

			$or_with = [];
			$or_select = [];
			$or_prepare = [];

			if (substr(key($or_search), 0, 1) == '!') {
				// NOTE(chris): only negative searchwords
				$sql = [];
				foreach ($or_search as $code => $words) {
					$code = substr($code, 1);
					// NOTE(chris): sort for performance reasons
					usort($words, function ($a, $b) {
						return strlen($b) - strlen($a);
					});
					$field_config = $table_config['searchfields'][$code];

					if (isset($field_config['prepare'])) {
						$this->_addPreparesToSqlWith($or_with, $field_config['prepare']);
						$or_prepare[$code] = $field_config['prepare'];
						unset($table_config['searchfields'][$code]['prepare']);
						unset($field_config['prepare']);
					}
					$field_sql = "
						SELECT
							" . $this->_formatPrimarykeys($table_config['primarykey'], $table_config['table']) . "
							FROM " . $table_config['table'] . "
							" . $this->_makeJoin($field_config['join'] ?? '') . "
							WHERE ";
					foreach ($words as $word) {
						$sql[] = $field_sql . $this->_makeCompareBool($field_config['comparison'], $field_config['field'], $word);
					}
				}

				$or_select[] = "
					SELECT 
						" . $this->_formatPrimarykeys($table_config['primarykey'], $table_config['table']) . ",
						1.0 AS rank
					FROM " . $table_config['table'] . "
					WHERE " . $table_config['primarykey'] . " NOT IN (" . implode(" UNION ", $sql) . ")";
			} else {
				$current_select = false;
				$count = 0;
				$skip = false;
				foreach ($or_search as $code => $words) {
					// NOTE(chris): sort for performance reasons
					if ($code && substr($code, 0, 1) == '!') {
						usort($words, function ($a, $b) {
							return strlen($a) - strlen($b);
						});
					} else {
						usort($words, function ($a, $b) {
							return strlen($b) - strlen($a);
						});
					}
					if ($code == '') {
						foreach ($words as $i => $word) {
							$field_sql = [];
							foreach ($table_config['searchfields'] as $c => $field_config) {
								if (in_array($field_config['comparison'], $integer_functions) && !is_numeric($word))
									continue;

								$word_from = $table_config['table'];
								$word_join = "";
								$word_rank = "0";
								if ($current_select) {
									$word_from = $current_select;
									if ($this->_needBasicTableJoin($field_config['field'], $table_config['primarykey'])) {
										$word_join .= " " . $this->_makeJoin($table_config);
									}
									$word_rank = "rank";
								}
								if (isset($field_config['prepare'])) {
									$this->_addPreparesToSqlWith($or_with, $field_config['prepare']);
									$or_prepare[$c] = $field_config['prepare'];
									unset($table_config['searchfields'][$c]['prepare']);
									unset($field_config['prepare']);
								}
								if (isset($field_config['join'])) {
									$word_join .= " " . $this->_makeJoin($field_config['join']);
								}
								$field_sql[] = "
									SELECT
										" . $this->_formatPrimarykeys($table_config['primarykey'], $word_from) . ",
										" . $word_rank . " AS w_rank,
										" . $this->_makeRank($field_config['comparison'], $field_config['field'], $word) . " AS rank
										FROM " . $word_from . "
										" . $word_join . "
										WHERE " . $this->_makeCompare($field_config['comparison'], $field_config['field'], $word);
							}
							// NOTE(chris): skip because the word is not numeric but all searchfields require integers
							if (!$field_sql) {
								$or_with = [];
								$or_select = [];
								$count = 0;
								$skip = true;
								foreach ($or_prepare as $k => $v)
									$table_config['searchfields'][$k]['prepare'] = $v;
								break;
							}

							$id = "w" . ($id_offset + count($or_with));
							$or_with[] = "
								" . $id . " (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
								SELECT
									" . $this->_formatPrimarykeys($table_config['primarykey']) . ",
									(w_rank + 1.0 - CASE " .
										"WHEN MIN(rank) = 0 THEN 0 " .
										"ELSE EXP(SUM(LN(CASE WHEN rank = 0 THEN 1 ELSE rank " .
										"END))) END) AS rank
									FROM (" . implode(' UNION ALL ', $field_sql) . ") " . $id . "
									GROUP BY " . $this->_formatPrimarykeys($table_config['primarykey']) . ", w_rank
								)";
							$current_select = $id;
						}
					} else {
						foreach ($words as $i => $word) {
							$where = "";
							$rank = "";
							$jointype = "";
							if (substr($code, 0, 1) == '!') {
								$c = substr($code, 1);
								$field_config = $table_config['searchfields'][$c];
								
								$rank = "1";

								$jointype = "LEFT";

								$where = $field_config['field'] .
									" IS NULL OR NOT (" .
									$this->_makeCompareBool(
										$field_config['comparison'],
										$field_config['field'],
										$word
									) .
									")";
								if ($field_config['1-n'] ?? false) {
									$where = "GROUP BY " .
										$this->_formatPrimarykeys($table_config['primarykey'], $current_select ?: $table_config['table']) .
										", rank HAVING MIN(CASE WHEN " .
										$where .
										" THEN 1 ELSE 0 END) = 1";
								} else {
									$where = "WHERE " . $where;
								}
							} else {
								$field_config = $table_config['searchfields'][$code];

								$rank = $this->_makeRank($field_config['comparison'], $field_config['field'], $word);

								$where = $this->_makeCompare($field_config['comparison'], $field_config['field'], $word);
								$where = "WHERE " . $where;
							}
							$word_from = $table_config['table'];
							$word_join = "";
							$word_rank = "";
							if ($current_select) {
								$word_from = $current_select;
								if ($this->_needBasicTableJoin($field_config['field'], $table_config['primarykey'])) {
									$word_join .= " " . $this->_makeJoin($table_config);
								}
								$word_rank = "rank + ";
							}
							if (isset($field_config['prepare'])) {
								$this->_addPreparesToSqlWith($or_with, $field_config['prepare']);
								$or_prepare[$code] = $field_config['prepare'];
								unset($table_config['searchfields'][$code]['prepare']);
								unset($field_config['prepare']);
							}
							if (isset($field_config['join'])) {
								$word_join .= " " . $this->_makeJoin($field_config['join'], $jointype);
							}

							$id = "w" . ($id_offset + count($or_with));
							$or_with[] = "
								" . $id . " (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
								SELECT
									" . $this->_formatPrimarykeys($table_config['primarykey'], $word_from) . ",
									" . $word_rank . $rank . " AS rank
									FROM " . $word_from . "
									" . $word_join . "
									" . $where . "
								)";
							$current_select = $id;
						}
					}
					if ($skip)
						break;
					$count += count($words);
				}

				if (!$count || !$current_select)
					continue;

				$or_select[] = "
					SELECT " . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank / " . $count . " AS rank FROM " . $current_select;
			}

			if ($or_with[0] === "RECURSIVE")
			{
				if (empty($sqlWith) || $sqlWith[0] !== "RECURSIVE")
					array_unshift($sqlWith, "RECURSIVE");
				array_shift($or_with);
			}

			$sqlWith = array_merge($sqlWith, $or_with);
			$sql_select = array_merge($sql_select, $or_select);
			$id_offset += count($or_with);
		}

		return $sql_select;
	}

	//------------------------------------------------------------------------------------------------------------------
	// Private methods

	/**
	 * Checks if the field is not one of the primarykeys.
	 *
	 * @param string							$field
	 * @param array|string						$primarykeys
	 *
	 * @return boolean
	 */
	private function _needBasicTableJoin($field, $primarykeys)
	{
		if (!is_array($primarykeys) && strpos($primarykeys, ",") !== false) {
			return $field != $primarykeys;
		}
		if (!is_array($primarykeys))
			$primarykeys = explode(",", $primarykeys);

		foreach ($primarykeys as $key) {
			if ($field == trim($key))
				return false;
		}
		return true;
	}

	/**
	 * Returns comma separated primarykeys. Optionally with table prefix
	 *
	 * @param array|string						$primarykeys
	 * @param string							$prefix
	 *
	 * @return string
	 */
	private function _formatPrimarykeys($primarykeys, $prefix = "")
	{
		if (is_array($primarykeys)) {
			if ($prefix)
				$prefix .= ".";
			return $prefix . implode(", " . $prefix, $primarykeys);
		}
		if (!$prefix)
			return $primarykeys;

		return $prefix . "." . implode(", " . $prefix . ".", explode(",", $primarykeys));
	}

	/**
	 * Adds the prepare statement to the sqlWith stack and handles the
	 * "RECURSIVE" modifier
	 *
	 * @param array								&$sqlWith
	 * @param array								$prepares
	 *
	 * @return void
	 */
	private function _addPreparesToSqlWith(&$sqlWith, $prepares)
	{
		$recursive = $sqlWith[0] ?? "" === "RECURSIVE";
		if (!is_array($prepares))
			$prepares = [$prepares];
		
		foreach ($prepares as $prep) {
			$prep = trim($prep);
			if (strtoupper(substr($prep, 0, 10)) === "RECURSIVE ") {
				$recursive = true;
				$sqlWith[] = substr($prep, 10);
			} else {
				$sqlWith[] = $prep;
			}
		}
		if ($recursive && $sqlWith[0] !== "RECURSIVE") {
			array_unshift($sqlWith, "RECURSIVE");
		}
	}

	/**
	 * Checks if an array has at least on non numeric value.
	 *
	 * @param array								$arr
	 *
	 * @return boolean
	 */
	private function _hasAtLeastOneNaN($arr)
	{
		foreach ($arr as $value)
			if (!is_numeric($value))
				return true;
		return false;
	}

	/**
	 * Helper function for getDynamicSearchSql
	 *
	 * @param array								$join
	 * @param string							$prefix
	 *
	 * @return string
	 */
	private function _makeJoin($join, $prefix = "")
	{
		if (!is_array($join))
			return "";
		if (!isset($join['table'])) {
			$output = [];
			foreach ($join as $j)
				$output[] = trim($this->_makeJoin($j, $prefix));
			return implode(" ", $output);
		}
		if (!isset($join['on']) && !isset($join['using']) && !isset($join['primarykey']))
			return "";
		$output = $prefix . " JOIN " . $join['table'];

		if (isset($join['using']))
			return $output . " USING (" . $join['using'] . ")";

		if (isset($join['primarykey']))
			return $output . " USING (" . $join['primarykey'] . ")";

		return $output . " ON (" . $join['on'] . ")";
	}

	/**
	 * Helper function for _makeRank, _makeCompare and _makeCompareBool
	 *
	 * @param string							$function
	 * @param string							$mode
	 * @param string							$field
	 * @param string							$word
	 *
	 * @return string
	 */
	private function _makeFunction($function, $mode, $field, $word)
	{
		$searchfunction = $this->_ci->config->item($mode, 'searchfunctions');

		if (!$searchfunction)
			return "";
		$tpl = $searchfunction[$function] ?? "";
		
		if (strstr($tpl, '{field}'))
			$tpl = str_replace('{field}', $field, $tpl);

		if (strstr($tpl, '{word}'))
			$tpl = str_replace('{word}', $this->_ci->db->escape($word), $tpl);
		if (strstr($tpl, '{like:word}'))
			$tpl = str_replace('{like:word}', "'%" . $this->_ci->db->escape_like_str($word) . "%'", $tpl);
		
		return $tpl;
	}

	/**
	 * Helper function for getDynamicSearchSql
	 *
	 * @param string							$mode
	 * @param string							$field
	 * @param string							$word
	 *
	 * @return string
	 */
	private function _makeRank($mode, $field, $word)
	{
		return $this->_makeFunction('rank', $mode, $field, $word);
	}

	/**
	 * Helper function for getDynamicSearchSql
	 *
	 * @param string							$mode
	 * @param string							$field
	 * @param string							$word
	 *
	 * @return string
	 */
	private function _makeCompare($mode, $field, $word)
	{
		return $this->_makeFunction('compare', $mode, $field, $word);
	}

	/**
	 * Helper function for getDynamicSearchSql
	 *
	 * @param string							$mode
	 * @param string							$field
	 * @param string							$word
	 *
	 * @return string
	 */
	private function _makeCompareBool($mode, $field, $word)
	{
		$searchfunction = $this->_ci->config->item($mode, 'searchfunctions');

		if (!$searchfunction)
			return "";
		$function = isset($searchfunction['compare_boolean']) ? 'compare_boolean' : 'compare';
		return $this->_makeFunction($function, $mode, $field, $word);
	}

	/**
	 * Converts the search string to an array.
	 * First level should be joined with an OR.
	 * Second level should be joined with an AND or AND NOT.
	 * It is an associative array where the key is a code for the field
	 * which the words should be compared with and the value is the array
	 * of words.
	 * Use AND NOT if the first letter in the key is "!".
	 * Use AND if the first letter in the key is not "!".
	 * E.g:
	 * If the key is:
	 * "": the words should be compared to all fields with AND.
	 * "!": the words should be compared to all fields with AND NOT.
	 * "somefield": the words should be compared to the field somefield with
	 * AND.
	 * "!somefield": the words should be compared to the field somefield with
	 * AND NOT.
	 *
	 * @param string							$searchstring
	 * @param array								$types
	 *
	 * @return array
	 */
	private function _convertQuery($searchstring, $types)
	{
		$searchAllTypes = count($types) == count($this->_ci->config->item('search'));
		$allowedTypes = array_keys($types);

		$currentArray = [];
		$outputArray = [];
		$cleanStrings = [];
		$cleanSearchstring = '';
		$filter = ['+' => [], '-' => []];
		$typeAliases = [];

		$tmp = explode(' ', strtolower($searchstring));
		while ($tmp) {
			$chunk = trim(array_shift($tmp));
			if ($chunk == '')
				continue;

			if (strpos($chunk, '"') !== false) {
				$test = explode('"', $chunk);
				if (count($test) > 2) {
					$rest = implode('"', array_slice($test, 2));
					if ($rest) {
						array_unshift($tmp, $rest);
						$chunk = implode('"', array_slice($test, 0, 2)) . '"';
					}
				}
				if (count($test) == 2) {
					while ($tmp && strpos($test[1], '"') === false) {
						$test[1] .= ' ' . trim(array_shift($tmp));
					}
					if (strpos($test[1], '"') === false) {
						$chunk = implode('"', $test) . '"';
					} else {
						$test2 = explode('"', $test[1], 2);
						$chunk = $test[0] . '"' . $test2[0] . '"';
						if ($test2[1]) {
							array_unshift($tmp, $test2[1]);
						}
					}
				}
				if (strpos($chunk, ' ') === false) {
					$chunk = str_replace('"', '', $chunk);
				}
			}

			if ($chunk == 'or') {
				$this->_convertQueryCleanupOr($currentArray, $cleanStrings, $filter, $searchAllTypes, $allowedTypes);
				$filter = ['+' => [], '-' => []];
				if ($currentArray) {
					$cleanSearchstring .= ($cleanSearchstring ? ' or ' : '') . implode(' ', $cleanStrings);
					$cleanStrings = [];
					$outputArray[] = $currentArray;
					$currentArray = [];
				}
				continue;
			}

			if ($chunk == ':' || $chunk == '-' || substr($chunk, -1) == ':')
				continue;

			if ($chunk[0] == ':' || ($chunk[0] == '-' && $chunk[1] == ':')) {
				if (!$typeAliases) {
					foreach ($types as $type => $config) {
						$typeAliases[$type] = $type;
						if (isset($config['alias'])) {
							foreach ($config['alias'] as $alias) {
								if (!isset($typeAliases[$alias]))
									$typeAliases[$alias] = $type;
							}
						}
					}
				}

				$test = explode(':', $chunk, 2);
				if (isset($typeAliases[$test[1]]))
					$chunk = $test[0] . ':' . $typeAliases[$test[1]];
				elseif ($test[0] == '-')
					continue;
			}

			if (in_array($chunk, $cleanStrings))
				continue;

			$cleanStrings[] = $chunk;

			$chunk = str_replace('"', '', $chunk);
			$code = '';

			if ($chunk[0] == '-') {
				$code = '!';
				$chunk = substr($chunk, 1);
			}
			if (strpos($chunk, ':') !== false) {
				$chunk = explode(':', $chunk, 2);
				if (!$chunk[0]) {
					$filter[$code ? '-' : '+'][] = $chunk[1];
					continue;
				}
				$code .= $chunk[0];
				$chunk = $chunk[1];
			}

			if (!isset($currentArray[$code]))
				$currentArray[$code] = [];
			
			$currentArray[$code][] = $chunk;
		}

		$this->_convertQueryCleanupOr($currentArray, $cleanStrings, $filter, $searchAllTypes, $allowedTypes);
		if ($currentArray) {
			$cleanSearchstring .= ($cleanSearchstring ? ' or ' : '') . implode(' ', $cleanStrings);
			$outputArray[] = $currentArray;
		}
		return [$outputArray, $cleanSearchstring];
	}

	private function _convertQueryCleanupOr(&$currentArray, &$cleanStrings, $filter, $searchAllTypes, $allowedTypes)
	{
		if ($filter['+'] && $filter['-']) {
			$double = array_intersect($filter['+'], $filter['-']);
			if ($double) {
				foreach ($double as $type) {
					array_splice($cleanStrings, array_search(':' . $type, $cleanStrings), 1);
					array_splice($cleanStrings, array_search('-:' . $type, $cleanStrings), 1);
				}
				$filter['+'] = array_diff($filter['+'], $double);
				$filter['-'] = array_diff($filter['-'], $double);
			}
			if (!$filter['+'] && !$filter['-']) {
				// All filters cancel each other out
				$currentArray = [];
				$cleanStrings = [];
				return;
			}
			if ($filter['+']) {
				foreach ($filter['-'] as $type) {
					array_splice($cleanStrings, array_search('-:' . $type, $cleanStrings), 1);
				}
				$filter['-'] = [];
			}
		}
		if ($filter['+']) {
			$cleanFilter = array_intersect($allowedTypes, $filter['+']);
			if (!$cleanFilter) {
				// All filters are forbidden
				$currentArray = [];
				$cleanStrings = [];
				return;
			}
			$forbiddenFilter = array_diff($cleanFilter, $filter['+']);
			foreach ($forbiddenFilter as $type) {
				array_splice($cleanStrings, array_search(':' . $type, $cleanStrings), 1);
			}
			$filter['+'] = $cleanFilter;
		} elseif ($filter['-']) {
			$filter['+'] = array_diff($allowedTypes, $filter['-']);
			if (!$searchAllTypes) {
				foreach ($filter['+'] as $type)
					$cleanStrings[] = ':' . $type;
				foreach ($filter['-'] as $type)
					array_splice($cleanStrings, array_search('-:' . $type, $cleanStrings), 1);
			}
		} else {
			if (!$searchAllTypes) {
				foreach ($allowedTypes as $type)
					$cleanStrings[] = ':' . $type;
			}
		}

		if ($filter['+']) {
			$currentArray['-filter'] = $filter['+'];
		}
	}
}
