<?php

namespace Rhymix\Framework\Parsers\DBQuery;

/**
 * Query class.
 */
class Query extends VariableBase
{
	/**
	 * Attributes common to all queries.
	 */
	public $name;
	public $type;
	public $tables = array();
	public $index_hints = array();
	public $columns = array();
	public $conditions = array();
	public $groupby = null;
	public $navigation = null;
	public $select_distinct = false;
	public $update_duplicate = false;
	public $requires_pagination = false;

	/**
	 * Attributes for subqueries in the <tables> or <columns> section.
	 */
	public $alias;
	public $join_type;
	public $join_conditions = array();

	/**
	 * Attributes for subqueries in the <conditions> section.
	 */
	public $operation;
	public $column;
	public $pipe;

	/**
	 * Attributes used during query string generation.
	 */
	protected $_prefix = '';
	protected $_args = array();
	protected $_column_list = array();
	protected $_params = array();

	/**
	 * Constants for alias handling.
	 */
	const ALIAS_NONE = 0;
	const ALIAS_SPECIFIED = 1;
	const ALIAS_ALWAYS = 2;

	/**
	 * Generate the query string for this query.
	 *
	 * @param string $prefix
	 * @param array $args
	 * @param array $column_list
	 * @param int $count_only
	 * @return string
	 */
	public function getQueryString(string $prefix = '', array $args = [], array $column_list = [], int $count_only = 0): string
	{
		// Save the query information.
		$this->_prefix = $prefix;
		$this->_args = $args;
		$this->_column_list = $column_list;
		$this->_params = array();

		// Call different internal methods depending on the query type.
		switch ($this->type)
		{
			case 'SELECT':
				$result = $this->_getSelectQueryString($count_only);
				break;
			case 'INSERT':
				$result = $this->_getInsertQueryString();
				break;
			case 'UPDATE':
				$result = $this->_getUpdateQueryString();
				break;
			case 'DELETE':
				$result = $this->_getDeleteQueryString();
				break;
			default:
				$result = '';
		}

		// Reset state and return the result.
		$this->_prefix = '';
		$this->_args = array();
		$this->_column_list = array();
		return $result;
	}

	/**
	 * Get the query parameters to use with the query string generated above.
	 *
	 * @return array
	 */
	public function getQueryParams(): array
	{
		return $this->_params;
	}

	/**
	 * Check if this query requires pagination.
	 *
	 * @return bool
	 */
	public function requiresPagination(): bool
	{
		return $this->requires_pagination;
	}

	/**
	 * Generate a SELECT query string.
	 *
	 * @param int $count_only
	 * @return string
	 */
	protected function _getSelectQueryString(int $count_only = 0): string
	{
		// Initialize the query string.
		$result = 'SELECT';
		$has_subquery_columns = false;

		// Compose the column list.
		if ($this->_column_list)
		{
			$column_list = implode(', ', array_map(function($str) {
				return self::quoteName($str);
			}, $this->_column_list));
		}
		else
		{
			$columns = array();
			foreach ($this->columns as $column)
			{
				if ($column->ifvar && empty($this->_args[$column->ifvar]))
				{
					continue;
				}
				elseif ($column instanceof self)
				{
					$has_subquery_columns = true;
					$subquery_count_only = $count_only ? $count_only + 1 : 0;
					$subquery = $column->getQueryString($this->_prefix, $this->_args, [], $subquery_count_only);
					foreach ($column->getQueryParams() as $param)
					{
						$this->_params[] = $param;
					}
					$columns[] = sprintf('(%s) AS %s', $subquery, self::quoteName($column->alias));
				}
				elseif ($column->is_expression && !$column->is_wildcard)
				{
					$columns[] = $column->name . ($column->alias ? (' AS ' . self::quoteName($column->alias)) : '');
				}
				elseif ($column->is_wildcard && $count_only >= 1 && !$this->select_distinct)
				{
					$columns[] = '1';
				}
				else
				{
					$columns[] = self::quoteName($column->name) . ($column->alias ? (' AS ' . self::quoteName($column->alias)) : '');
				}
			}
			$column_list = implode(', ', $columns);
		}

		// Replace the column list if this is a count-only query.
		if ($count_only == 1)
		{
			$count_wrap = ($this->groupby || $this->select_distinct || $has_subquery_columns || preg_match('/\bDISTINCT\b/i', $column_list));
			if ($count_wrap)
			{
				$result .= ($this->select_distinct ? ' DISTINCT ' : ' ') . $column_list;
			}
			else
			{
				$result .= ' COUNT(*) AS `count`';
			}
		}
		else
		{
			$count_wrap = false;
			$result .= ($this->select_distinct ? ' DISTINCT ' : ' ') . $column_list;
		}

		// Compose the FROM clause.
		if (count($this->tables))
		{
			$tables = $this->_arrangeTables($this->tables, self::ALIAS_ALWAYS);
			if ($tables !== '')
			{
				$result .= ' FROM ' . $tables;
			}
		}
		if (count($this->index_hints))
		{
			$index_hints = $this->_arrangeIndexHints($this->index_hints);
			if ($index_hints !== '')
			{
				$result .= ' ' . $index_hints;
			}
		}

		// Compose the WHERE clause.
		if (count($this->conditions))
		{
			$where = $this->_arrangeConditions($this->conditions);
			if ($where !== '')
			{
				$result .= ' WHERE ' . $where;
			}
		}

		// Compose the GROUP BY clause.
		if ($this->groupby && count($this->groupby->columns) && (!$this->groupby->ifvar || !empty($this->_args[$this->groupby->ifvar])))
		{
			$columns = array();
			foreach ($this->groupby->columns as $column_name)
			{
				if (is_array($column_name))
				{
					list($column_name, $ifvar) = $column_name;
					if ($ifvar && empty($this->_args[$ifvar]))
					{
						continue;
					}
				}
				if (self::isValidColumnName($column_name))
				{
					$columns[] = self::quoteName($column_name);
				}
				else
				{
					$columns[] = $column_name;
				}
			}
			$result .= ' GROUP BY ' . implode(', ', $columns);
		}
		if ($this->groupby && count($this->groupby->having) && (!$this->groupby->ifvar || !empty($this->_args[$this->groupby->ifvar])))
		{
			$having = $this->_arrangeConditions($this->groupby->having);
			if ($having !== '')
			{
				$result .= ' HAVING ' . $having;
			}
		}

		// Compose the ORDER BY clause.
		if ($this->navigation && count($this->navigation->orderby) && !$count_only)
		{
			$order_by = $this->_arrangeOrderBy($this->navigation);
			if ($order_by !== '')
			{
				$result .= ' ORDER BY ' . $order_by;
			}
		}

		// Compose the LIMIT/OFFSET clause.
		if ($this->navigation && $this->navigation->list_count && !$count_only)
		{
			$limit_offset = $this->_arrangeLimitOffset($this->navigation);
			if ($limit_offset !== '')
			{
				$result .= ' LIMIT ' . $limit_offset;
			}
		}

		// Wrap in a subquery if necesary.
		if ($count_wrap)
		{
			$result = 'SELECT COUNT(*) AS `count` FROM (' . $result . ') AS `subquery`';
		}

		// Return the final query string.
		return $result;
	}

	/**
	 * Generate a INSERT query string.
	 *
	 * @return string
	 */
	protected function _getInsertQueryString(): string
	{
		// Initialize the query string.
		$result = 'INSERT';

		// Compose the INTO clause.
		if (count($this->tables))
		{
			$tables = $this->_arrangeTables($this->tables, self::ALIAS_NONE);
			if ($tables !== '')
			{
				$result .= ' INTO ' . $tables;
			}
		}

		// Process the SET clause with new values.
		$columns = array();
		foreach ($this->columns as $column)
		{
			if ($column->ifvar && empty($this->_args[$column->ifvar]))
			{
				continue;
			}

			$setval_string = $this->_parseCondition($column);
			if ($setval_string !== '')
			{
				$columns[] = $setval_string;
			}
		}
		$result .= ' SET ' . implode(', ', $columns);

		// Process the ON DUPLICATE KEY UPDATE (upsert) clause.
		if ($this->update_duplicate && count($columns))
		{
			$result .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $columns);
			$duplicate_params = $this->_params;
			foreach ($duplicate_params as $param)
			{
				$this->_params[] = $param;
			}
		}

		// Return the final query string.
		return $result;
	}

	/**
	 * Generate a UPDATE query string.
	 *
	 * @return string
	 */
	protected function _getUpdateQueryString(): string
	{
		// Initialize the query string.
		$result = 'UPDATE ';

		// Compose the INTO clause.
		if (count($this->tables))
		{
			$tables = $this->_arrangeTables($this->tables, self::ALIAS_SPECIFIED);
			if ($tables !== '')
			{
				$result .= $tables;
			}
		}

		// Compose the SET clause with updated values.
		$columns = array();
		foreach ($this->columns as $column)
		{
			if ($column->ifvar && empty($this->_args[$column->ifvar]))
			{
				continue;
			}

			$setval_string = $this->_parseCondition($column);
			if ($setval_string !== '')
			{
				$columns[] = $setval_string;
			}
		}
		$result .= ' SET ' . implode(', ', $columns);

		// Compose the WHERE clause.
		if (count($this->conditions))
		{
			$where = $this->_arrangeConditions($this->conditions);
			if ($where !== '')
			{
				$result .= ' WHERE ' . $where;
			}
		}

		// Return the final query string.
		return $result;
	}

	/**
	 * Generate a DELETE query string.
	 *
	 * @return string
	 */
	protected function _getDeleteQueryString(): string
	{
		// Initialize the query string.
		$result = 'DELETE';

		// Compose the FROM clause.
		if (count($this->tables))
		{
			$tables = $this->_arrangeTables($this->tables, self::ALIAS_NONE);
			if ($tables !== '')
			{
				$result .= ' FROM ' . $tables;
			}
		}

		// Compose the WHERE clause.
		if (count($this->conditions))
		{
			$where = $this->_arrangeConditions($this->conditions);
			if ($where !== '')
			{
				$result .= ' WHERE ' . $where;
			}
		}

		// Compose the ORDER BY clause.
		if ($this->navigation && count($this->navigation->orderby))
		{
			$result .= ' ORDER BY ' . $this->_arrangeOrderBy($this->navigation);
		}

		// Compose the LIMIT/OFFSET clause.
		if ($this->navigation && $this->navigation->list_count)
		{
			$limit_offset = $this->_arrangeLimitOffset($this->navigation);
			if ($limit_offset !== '')
			{
				$result .= ' LIMIT ' . $limit_offset;
			}
		}

		// Return the final query string.
		return $result;
	}

	/**
	 * Generate a FROM clause from a list of tables.
	 *
	 * @param array $tables
	 * @param int $use_aliases
	 * @return string
	 */
	protected function _arrangeTables(array $tables, int $use_aliases = self::ALIAS_SPECIFIED): string
	{
		// Initialize the result.
		$result = array();

		// Process each table definition.
		foreach ($tables as $table)
		{
			// Skip
			if ($table->ifvar && empty($this->_args[$table->ifvar]))
			{
				continue;
			}

			// Subquery
			if ($table instanceof self)
			{
				$tabledef = '(' . $table->getQueryString($this->_prefix, $this->_args) . ')';
				$table->alias = $table->alias ?: $table->name;
				if ($table->alias)
				{
					$tabledef .= ' AS `' . $table->alias . '`';
				}
				foreach ($table->getQueryParams() as $param)
				{
					$this->_params[] = $param;
				}
			}

			// Regular table
			else
			{
				$tabledef = self::quoteName($this->_prefix . $table->name);
				if ($use_aliases === self::ALIAS_ALWAYS)
				{
					$table->alias = $table->alias ?: $table->name;
				}
				if ($use_aliases !== self::ALIAS_NONE && $table->alias && $table->alias !== ($this->_prefix . $table->name))
				{
					$tabledef .= ' AS `' . $table->alias . '`';
				}
			}

			// Add join conditions
			if ($table->join_type)
			{
				$join_where = $this->_arrangeConditions($table->join_conditions);
				if ($join_where !== '')
				{
					$tabledef = $tabledef . ' ON ' . $join_where;
				}
				$result[] = ' ' . $table->join_type . ' ' . $tabledef;
			}
			else
			{
				$result[] = (count($result) ? ', ' : '') . $tabledef;
			}
		}

		// Combine the result and return as a string.
		return implode('', $result);
	}

	/**
	 * Generate index hints.
	 *
	 * @param array $index_hints
	 * @return string
	 */
	protected function _arrangeIndexHints(array $index_hints): string
	{
		// Initialize the index list by type.
		$index_list = [];

		// Group each index hint by type.
		foreach ($index_hints as $index_hint)
		{
			// Skip
			if ($index_hint->ifvar && empty($this->_args[$index_hint->ifvar]))
			{
				continue;
			}

			if (!count($index_hint->target_db) || isset($index_hint->target_db['mysql']))
			{
				$key = $index_hint->hint_type ?: 'USE';
				$index_list[$key] = $index_list[$key] ?? [];
				if ($index_hint->var && isset($this->_args[$index_hint->var]))
				{
					$index_list[$key][] = self::quoteName($this->_args[$index_hint->var]);
				}
				elseif ($index_hint->index_name)
				{
					$index_list[$key][] = self::quoteName($index_hint->index_name);
				}
			}
		}

		// Generate a list of indexes for each group.
		$result = [];
		foreach ($index_list as $key => $val)
		{
			if (count($val))
			{
				$result[] = sprintf('%s INDEX (%s)', $key, implode(', ', $val));
			}
		}
		return implode(' ', $result);
	}

	/**
	 * Generate a WHERE clause from a list of conditions.
	 *
	 * @param array $conditions
	 * @return string
	 */
	protected function _arrangeConditions(array $conditions): string
	{
		// Initialize the result.
		$result = '';

		// Process each condition.
		foreach ($conditions as $condition)
		{
			// Skip
			if ($condition->ifvar && empty($this->_args[$condition->ifvar]))
			{
				continue;
			}

			// Subquery
			if ($condition instanceof self)
			{
				$condition_string = $this->_parseCondition($condition);
				if ($condition_string !== '')
				{
					$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . $condition_string;
				}
			}

			// Condition group
			elseif ($condition instanceof ConditionGroup)
			{
				$condition_string = $this->_arrangeConditions($condition->conditions);
				if ($condition_string !== '')
				{
					$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . '(' . $condition_string . ')';
				}
				elseif ($condition->not_null)
				{
					throw new \Rhymix\Framework\Exceptions\QueryError('Condition group marked as NOT NULL must contain at least one valid condition');
				}
			}

			// Simple condition
			else
			{
				$condition_string = $this->_parseCondition($condition);
				if ($condition_string !== '')
				{
					$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . $condition_string;
				}
			}
		}

		// Return the WHERE clause.
		return $result;
	}

	/**
	 * Generate a ORDER BY clause from navigation settings.
	 *
	 * @param Navigation $navigation
	 * @return string
	 */
	protected function _arrangeOrderBy(Navigation $navigation): string
	{
		// Initialize the result.
		$result = array();

		// Process each column definition.
		foreach ($navigation->orderby as $orderby)
		{
			// Skip
			if ($orderby->ifvar && empty($this->_args[$orderby->ifvar]))
			{
				continue;
			}

			// Get the name of the column or expression to order by.
			$column_name = '';
			list($column_name, $is_expression, $is_default_value) = $orderby->getValue($this->_args);
			if (!$column_name)
			{
				continue;
			}
			if (!$is_expression && self::isValidColumnName($column_name))
			{
				$column_name = self::quoteName($column_name);
			}
			elseif (!$is_default_value)
			{
				continue;
			}

			// Get the ordering (ASC or DESC).
			if (preg_match('/^(ASC|DESC)$/i', $orderby->order_var ?: '', $matches))
			{
				$column_order = strtoupper($matches[1]);
			}
			elseif (isset($this->_args[$orderby->order_var]))
			{
				$column_order = preg_replace('/[^A-Z]/', '', strtoupper($this->_args[$orderby->order_var]));
			}
			else
			{
				$column_order = preg_replace('/[^A-Z]/', '', strtoupper($orderby->order_default));
			}

			$result[] = $column_name . ' ' . $column_order;
		}

		// Return the ORDER BY clause.
		return implode(', ', $result);
	}

	/**
	 * Generate a LIMIT/OFFSET clause from navigation settings.
	 *
	 * @param Navigation $navigation
	 * @return string
	 */
	protected function _arrangeLimitOffset(Navigation $navigation): string
	{
		$list_count = 0;
		$page = 0;
		$offset = 0;

		// Get the list count.
		if (!$navigation->list_count->ifvar || !empty($this->_args[$navigation->list_count->ifvar]))
		{
			$list_count = $navigation->list_count->getValue($this->_args)[0];
		}
		if ($list_count <= 0)
		{
			return '';
		}

		// Get the offset from the page or offset variable.
		if ($navigation->page)
		{
			if (!$navigation->page->ifvar || !empty($this->_args[$navigation->page->ifvar]))
			{
				$page = $navigation->page->getValue($this->_args)[0];
			}
		}
		if ($navigation->offset)
		{
			if (!$navigation->offset->ifvar || !empty($this->_args[$navigation->offset->ifvar]))
			{
				$offset = $navigation->offset->getValue($this->_args)[0];
			}
		}

		// If page is available, set the offset and require pagination for this query.
		if ($page > 0)
		{
			$offset = $list_count * ($page - 1);
			if ($this->type === 'SELECT')
			{
				$this->requires_pagination = true;
			}
		}
		else
		{
			$page = 1;
		}

		// Return the LIMIT/OFFSET clause.
		return ($offset > 0 ? (intval($offset) . ', ') : '') . intval($list_count);
	}

	/**
	 * Generate each condition in a WHERE clause.
	 *
	 * @param VariableBase $condition
	 * @return string
	 */
	protected function _parseCondition(VariableBase $condition): string
	{
		list($where, $params) = $condition->getQueryStringAndParams($this->_args, $this->_prefix);
		foreach ($params as $param)
		{
			$this->_params[] = $param;
		}
		return $where;
	}

	/**
	 * Quote a column name.
	 *
	 * @param string $column_name
	 * @return string
	 */
	public static function quoteName(string $column_name): string
	{
		$exceptions = ['*' => true, 'DISTINCT' => true, 'distinct' => true];
		return preg_replace_callback('/[a-z][a-z0-9_.*]*(?!\\()\b/i', function($m) use($exceptions) {
			$columns = explode('.', $m[0]);
			$columns = array_map(function($str) use($exceptions) {
				return isset($exceptions[$str]) ? $str : ('`' . $str . '`');
			}, $columns);
			return implode('.', $columns);
		}, $column_name);
	}

	/**
	 * Check if a column name is valid.
	 *
	 * @param string $column_name
	 * @return bool
	 */
	public static function isValidColumnName(string $column_name): bool
	{
		return preg_match('/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/i', $column_name) ? true : false;
	}

	/**
	 * Check if a variable is considered valid for XE compatibility.
	 *
	 * @param mixed $var
	 * @param bool $allow_empty_string
	 * @return bool
	 */
	public static function isValidVariable($var, bool $allow_empty_string = true): bool
	{
		if ($var === null || ($var === '' && !$allow_empty_string))
		{
			return false;
		}

		if (is_array($var))
		{
			$count = count($var);
			if ($count === 0 || ($count === 1 && reset($var) === ''))
			{
				return false;
			}
		}

		return true;
	}
}
