<?php

namespace SilverStripe\ORM\Queries;

use SilverStripe\Dev\Deprecation;

/**
 * Represents a SQL query for an expression which interacts with existing rows
 * (SELECT / DELETE / UPDATE) with a WHERE clause
 */
abstract class SQLConditionalExpression extends SQLExpression
{

    /**
     * An array of WHERE clauses.
     *
     * Each item in this array will be in the form of a single-length array
     * in the format array('predicate' => array($parameters))
     *
     * @var array
     */
    protected $where = [];

    /**
     * The logical connective used to join WHERE clauses. Defaults to AND.
     *
     * @var string
     */
    protected $connective = 'AND';

    /**
     * An array of tables. The first one is just the table name.
     * Used as the FROM in DELETE/SELECT statements, the INTO in INSERT statements,
     * and the target table in UPDATE statements
     *
     * The keys of this array are the aliases of the tables (unquoted), where the
     * values are either the literal table names, or an array with join details.
     *
     * @see SQLConditionalExpression::addLeftJoin()
     *
     * @var array
     */
    protected $from = [];

    /**
     * Construct a new SQLInteractExpression.
     *
     * @param array|string $from An array of Tables (FROM clauses). The first one should be just the table name.
     * @param array $where An array of WHERE clauses.
     */
    function __construct($from = [], $where = [])
    {
        $this->setFrom($from);
        $this->setWhere($where);
    }

    /**
     * Sets the list of tables to query from or update
     *
     * @example $query->setFrom('"MyTable"'); // SELECT * FROM "MyTable"
     *
     * @param string|array $from Single, or list of, ANSI quoted table names
     * @return $this
     */
    public function setFrom($from)
    {
        $this->from = [];
        return $this->addFrom($from);
    }

    /**
     * Add a table to include in the query or update
     *
     * @example $query->addFrom('"MyTable"'); // SELECT * FROM "MyTable"
     *
     * @param string|array $from Single, or list of, ANSI quoted table names
     * @return $this Self reference
     */
    public function addFrom($from)
    {
        if (is_array($from)) {
            $this->from = array_merge($this->from, $from);
        } elseif (!empty($from)) {
            $this->from[str_replace(['"','`'], '', $from)] = $from;
        }

        return $this;
    }

    /**
     * Set the connective property.
     *
     * @param string $value either 'AND' or 'OR'
     */
    public function setConnective($value)
    {
        $this->connective = $value;
    }

    /**
     * Get the connective property.
     *
     * @return string 'AND' or 'OR'
     */
    public function getConnective()
    {
        return $this->connective;
    }

    /**
     * Use the disjunctive operator 'OR' to join filter expressions in the WHERE clause.
     */
    public function useDisjunction()
    {
        $this->setConnective('OR');
    }

    /**
     * Use the conjunctive operator 'AND' to join filter expressions in the WHERE clause.
     */
    public function useConjunction()
    {
        $this->setConnective('AND');
    }

    /**
     * Add a LEFT JOIN criteria to the tables list.
     *
     * @param string $table Unquoted table name
     * @param string $onPredicate The "ON" SQL fragment in a "LEFT JOIN ... AS ... ON ..." statement, Needs to be valid
     *                            (quoted) SQL.
     * @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
     *                   will cause the query to appear first. The default is 20, and joins created automatically by the
     *                   ORM have a value of 10.
     * @param array $parameters Any additional parameters if the join is a parameterized subquery
     * @return $this Self reference
     */
    public function addLeftJoin($table, $onPredicate, $tableAlias = '', $order = 20, $parameters = [])
    {
        if (!$tableAlias) {
            $tableAlias = $table;
        }
        $this->from[$tableAlias] = [
            'type' => 'LEFT',
            'table' => $table,
            'filter' => [$onPredicate],
            'order' => $order,
            'parameters' => $parameters
        ];
        return $this;
    }

    /**
     * Add an INNER JOIN criteria
     *
     * @param string $table Unquoted table name
     * @param string $onPredicate The "ON" SQL fragment in an "INNER JOIN ... AS ... ON ..." statement. Needs to be
     * valid (quoted) SQL.
     * @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on
     * @param int $order A numerical index to control the order that joins are added to the query; lower order
     * values will cause the query to appear first. The default is 20, and joins created automatically by the
     * ORM have a value of 10.
     * @param array $parameters Any additional parameters if the join is a parameterized subquery
     * @return $this Self reference
     */
    public function addInnerJoin($table, $onPredicate, $tableAlias = null, $order = 20, $parameters = [])
    {
        if (!$tableAlias) {
            $tableAlias = $table;
        }
        $this->from[$tableAlias] = [
            'type' => 'INNER',
            'table' => $table,
            'filter' => [$onPredicate],
            'order' => $order,
            'parameters' => $parameters
        ];
        return $this;
    }

    /**
     * Add an additional filter (part of the ON clause) on a join.
     *
     * @param string $table Table to join on from the original join (unquoted)
     * @param string $filter The "ON" SQL fragment (escaped)
     * @return $this Self reference
     */
    public function addFilterToJoin($table, $filter)
    {
        $this->from[$table]['filter'][] = $filter;
        return $this;
    }

    /**
     * Set the filter (part of the ON clause) on a join.
     *
     * @param string $table Table to join on from the original join (unquoted)
     * @param string $filter The "ON" SQL fragment (escaped)
     * @return $this Self reference
     */
    public function setJoinFilter($table, $filter)
    {
        $this->from[$table]['filter'] = [$filter];
        return $this;
    }

    /**
     * Returns true if we are already joining to the given table alias
     *
     * @param string $tableAlias Table name
     * @return boolean
     */
    public function isJoinedTo($tableAlias)
    {
        return isset($this->from[$tableAlias]);
    }

    /**
     * Return a list of tables that this query is selecting from.
     *
     * @return array Unquoted table names
     */
    public function queriedTables()
    {
        $tables = [];

        foreach ($this->from as $key => $tableClause) {
            if (is_array($tableClause)) {
                $table = '"' . $tableClause['table'] . '"';
            } elseif (is_string($tableClause) && preg_match('/JOIN +("[^"]+") +(AS|ON) +/i', $tableClause, $matches)) {
                $table = $matches[1];
            } else {
                $table = $tableClause;
            }

            // Handle string replacements
            if ($this->replacementsOld) {
                $table = str_replace($this->replacementsOld, $this->replacementsNew, $table);
            }

            $tables[] = preg_replace('/^"|"$/', '', $table);
        }

        return $tables;
    }

    /**
     * Return a list of tables queried
     *
     * @return array
     */
    public function getFrom()
    {
        return $this->from;
    }

    /**
     * Retrieves the finalized list of joins
     *
     * @todo This part of the code could be simplified
     *
     * @param array $parameters Out variable for parameters required for this query
     * @return array List of joins as a mapping from array('Alias' => 'Join Expression')
     */
    public function getJoins(&$parameters = [])
    {
        if (func_num_args() == 0) {
            Deprecation::notice(
                '4.0',
                'SQLConditionalExpression::getJoins() now may produce parameters which are necessary to
				execute this query'
            );
        }

        // Sort the joins
        $parameters = [];
        $joins = $this->getOrderedJoins($this->from);

        // Build from clauses
        foreach ($joins as $alias => $join) {
            // $join can be something like this array structure
            // array('type' => 'inner', 'table' => 'SiteTree', 'filter' => array("SiteTree.ID = 1",
            // "Status = 'approved'", 'order' => 20))
            if (!is_array($join)) {
                if (empty($alias) || is_numeric($alias)) {
                    continue;
                }

                if (preg_match('/AS\s+(?:"[^"]+"|[A-Z0-9_]+)\s*$/i', $join)) {
                    // custom aliases override the ones defined through array keys
                    // this is only meant to keep backward compatibility with SS <= 4.3,
                    // to be removed in SS5
                    continue;
                }

                $trimmedAlias = trim($alias, '"');

                if ($trimmedAlias !== trim($join, '"')) {
                    $joins[$alias] = "{$join} AS \"{$trimmedAlias}\"";
                }

                continue;
            }

            if (is_string($join['filter'])) {
                $filter = $join['filter'];
            } elseif (sizeof($join['filter']) == 1) {
                $filter = $join['filter'][0];
            } else {
                $filter = "(" . implode(") AND (", $join['filter']) . ")";
            }

            // Ensure tables are quoted, unless the table is actually a sub-select
            $table = preg_match('/\bSELECT\b/i', $join['table'])
                ? $join['table']
                : "\"{$join['table']}\"";
            $aliasClause = ($alias != $join['table'])
                ? " AS \"{$alias}\""
                : "";
            $joins[$alias] = strtoupper($join['type']) . " JOIN " . $table . "$aliasClause ON $filter";
            if (!empty($join['parameters'])) {
                $parameters = array_merge($parameters, $join['parameters']);
            }
        }

        return $joins;
    }

    /**
     * Ensure that framework "auto-generated" table JOINs are first in the finalised SQL query.
     * This prevents issues where developer-initiated JOINs attempt to JOIN using relations that haven't actually
     * yet been scaffolded by the framework. Demonstrated by PostGres in errors like:
     *"...ERROR: missing FROM-clause..."
     *
     * @param $from array - in the format of $this->from
     * @return array - and reorderded list of selects
     */
    protected function getOrderedJoins($from)
    {
        if (count($from) <= 1) {
            return $from;
        }

        // shift the first FROM table out from so we only deal with the JOINs
        reset($from);
        $baseFromAlias = key($from);
        $baseFrom = array_shift($from);

        $this->mergesort($from, function ($firstJoin, $secondJoin) {
            if (!is_array($firstJoin)
                || !is_array($secondJoin)
                || $firstJoin['order'] == $secondJoin['order']
            ) {
                return 0;
            } else {
                return ($firstJoin['order'] < $secondJoin['order']) ?  -1 : 1;
            }
        });

        // Put the first FROM table back into the results
        if (!empty($baseFromAlias) && !is_numeric($baseFromAlias)) {
            $from = array_merge([$baseFromAlias => $baseFrom], $from);
        } else {
            array_unshift($from, $baseFrom);
        }

        return $from;
    }

    /**
     * Since uasort don't preserve the order of an array if the comparison is equal
     * we have to resort to a merge sort. It's quick and stable: O(n*log(n)).
     *
     * @see http://stackoverflow.com/q/4353739/139301
     *
     * @param array $array The array to sort (by reference)
     * @param callable|string $cmpFunction The function to use for comparison
     */
    protected function mergesort(&$array, $cmpFunction = 'strcmp')
    {
        // Arrays of size < 2 require no action.
        if (count($array) < 2) {
            return;
        }
        // Split the array in half
        $halfway = count($array) / 2;
        $array1 = array_slice($array, 0, $halfway);
        $array2 = array_slice($array, $halfway);
        // Recurse to sort the two halves
        $this->mergesort($array1, $cmpFunction);
        $this->mergesort($array2, $cmpFunction);
        // If all of $array1 is <= all of $array2, just append them.
        if (call_user_func($cmpFunction, end($array1), reset($array2)) < 1) {
            $array = array_merge($array1, $array2);
            return;
        }
        // Merge the two sorted arrays into a single sorted array
        $array = [];
        $val1 = reset($array1);
        $val2 = reset($array2);
        do {
            if (call_user_func($cmpFunction, $val1, $val2) < 1) {
                $array[key($array1)] = $val1;
                $val1 = next($array1);
            } else {
                $array[key($array2)] = $val2;
                $val2 = next($array2);
            }
        } while ($val1 && $val2);

        // Merge the remainder
        while ($val1) {
            $array[key($array1)] = $val1;
            $val1 = next($array1);
        }
        while ($val2) {
            $array[key($array2)] = $val2;
            $val2 = next($array2);
        }
        return;
    }

    /**
     * Set a WHERE clause.
     *
     * @see SQLConditionalExpression::addWhere() for syntax examples
     *
     * @param mixed ...$where Predicate(s) to set, as escaped SQL statements or parameterized queries
     * @return $this Self reference
     */
    public function setWhere($where)
    {
        $where = func_num_args() > 1 ? func_get_args() : $where;
        $this->where = [];
        return $this->addWhere($where);
    }

    /**
     * Adds a WHERE clause.
     *
     * Note that the database will execute any parameterized queries using
     * prepared statements whenever available.
     *
     * There are several different ways of doing this.
     *
     * <code>
     *  // the entire predicate as a single string
     *  $query->addWhere("\"Column\" = 'Value'");
     *
     *  // multiple predicates as an array
     *  $query->addWhere(array("\"Column\" = 'Value'", "\"Column\" != 'Value'"));
     *
     *  // Shorthand for the above using argument expansion
     *  $query->addWhere("\"Column\" = 'Value'", "\"Column\" != 'Value'");
     *
     *  // multiple predicates with parameters
     *  $query->addWhere(array('"Column" = ?' => $column, '"Name" = ?' => $value)));
     *
     *  // Shorthand for simple column comparison (as above), omitting the '?'
     *  $query->addWhere(array('"Column"' => $column, '"Name"' => $value));
     *
     *  // Multiple predicates, each with multiple parameters.
     *  $query->addWhere(array(
     *      '"ColumnOne" = ? OR "ColumnTwo" != ?' => array(1, 4),
     *      '"ID" != ?' => $value
     *  ));
     *
     *  // Using a dynamically generated condition (any object that implements SQLConditionGroup)
     *  $condition = new ObjectThatImplements_SQLConditionGroup();
     *  $query->addWhere($condition);
     *
     * </code>
     *
     * Note that if giving multiple parameters for a single predicate the array
     * of values must be given as an indexed array, not an associative array.
     *
     * Also should be noted is that any null values for parameters may give unexpected
     * behaviour. array('Column' => NULL) is shorthand for array('Column = ?', NULL), and
     * will not match null values for that column, as 'Column IS NULL' is the correct syntax.
     *
     * Additionally, be careful of key conflicts. Adding two predicates with the same
     * condition but different parameters can cause a key conflict if added in the same array.
     * This can be solved by wrapping each individual condition in an array. E.g.
     *
     * <code>
     * // Multiple predicates with duplicate conditions
     *  $query->addWhere(array(
     *      array('ID != ?' => 5),
     *      array('ID != ?' => 6)
     *  ));
     *
     * // Alternatively this can be added in two separate calls to addWhere
     * $query->addWhere(array('ID != ?' => 5));
     * $query->addWhere(array('ID != ?' => 6));
     *
     * // Or simply omit the outer array
     * $query->addWhere(array('ID != ?' => 5), array('ID != ?' => 6));
     * </code>
     *
     * If it's necessary to force the parameter to be considered as a specific data type
     * by the database connector's prepared query processor any parameter can be cast
     * to that type by using the following format.
     *
     * <code>
     *  // Treat this value as a double type, regardless of its type within PHP
     *  $query->addWhere(array(
     *      'Column' => array(
     *          'value' => $variable,
     *          'type' => 'double'
     *      )
     *  ));
     * </code>
     *
     * @param mixed ...$where Predicate(s) to set, as escaped SQL statements or parameterized queries
     * @return $this Self reference
     */
    public function addWhere($where)
    {
        $where = $this->normalisePredicates(func_get_args());

        // If the function is called with an array of items
        $this->where = array_merge($this->where, $where);

        return $this;
    }

    /**
     * @see SQLConditionalExpression::addWhere()
     *
     * @param mixed ...$filters Predicate(s) to set, as escaped SQL statements or parameterized queries
     * @return $this Self reference
     */
    public function setWhereAny($filters)
    {
        $filters = func_num_args() > 1 ? func_get_args() : $filters;
        return $this
            ->setWhere([])
            ->addWhereAny($filters);
    }

    /**
     * @see SQLConditionalExpression::addWhere()
     *
     * @param mixed ...$filters Predicate(s) to set, as escaped SQL statements or parameterized queries
     * @return $this Self reference
     */
    public function addWhereAny($filters)
    {
        // Parse and split predicates along with any parameters
        $filters = $this->normalisePredicates(func_get_args());
        $this->splitQueryParameters($filters, $predicates, $parameters);

        $clause = "(" . implode(") OR (", $predicates) . ")";
        return $this->addWhere([$clause => $parameters]);
    }

    /**
     * Return a list of WHERE clauses used internally.
     *
     * @return array
     */
    public function getWhere()
    {
        return $this->where;
    }

    /**
     * Return a list of WHERE clauses used internally.
     *
     * @param array $parameters Out variable for parameters required for this query
     * @return array
     */
    public function getWhereParameterised(&$parameters)
    {
        $this->splitQueryParameters($this->where, $predicates, $parameters);
        return $predicates;
    }

    /**
     * Given a key / value pair, extract the predicate and any potential paramaters
     * in a format suitable for storing internally as a list of parameterized conditions.
     *
     * @param string|integer $key The left hand (key index) of this condition.
     * Could be the predicate or an integer index.
     * @param mixed $value The The right hand (array value) of this condition.
     * Could be the predicate (if non-parameterized), or the parameter(s). Could also be
     * an array containing a nested condition in the similar format this function outputs.
     * @return array|SQLConditionGroup A single item array in the format
     * array($predicate => array($parameters)), unless it's a SQLConditionGroup
     */
    protected function parsePredicate($key, $value)
    {
        // If a string key is given then presume this is a parameterized condition
        if ($value instanceof SQLConditionGroup) {
            return $value;
        } elseif (is_string($key)) {
            // Extract the parameter(s) from the value
            if (!is_array($value) || isset($value['type'])) {
                $parameters = [$value];
            } else {
                $parameters = array_values($value);
            }

            // Append '= ?' if not present, parameters are given, and we have exactly one parameter
            if (strpos($key, '?') === false) {
                $parameterCount = count($parameters);
                if ($parameterCount === 1) {
                    $key .= " = ?";
                } elseif ($parameterCount > 1) {
                    throw new \InvalidArgumentException(
                        "Incorrect number of '?' in predicate $key. Expected $parameterCount but none given."
                    );
                }
            }
            return [$key => $parameters];
        } elseif (is_array($value)) {
            // If predicates are nested one per array (as per the internal format)
            // then run a quick check over the contents and recursively parse
            if (count($value) !== 1) {
                throw new \InvalidArgumentException(
                    'Nested predicates should be given as a single item array in '
                        . 'array($predicate => array($prameters)) format)'
                );
            }
            foreach ($value as $key => $pairValue) {
                return $this->parsePredicate($key, $pairValue);
            }
        } else {
            // Non-parameterized condition
            return [$value => []];
        }
    }

    /**
     * Given a list of conditions in any user-acceptable format, convert this
     * to an array of parameterized predicates suitable for merging with $this->where.
     *
     * Normalised predicates are in the below format, in order to avoid key collisions.
     *
     * <code>
     * array(
     *  array('Condition != ?' => array('parameter')),
     *  array('Condition != ?' => array('otherparameter')),
     *  array('Condition = 3' => array()),
     *  array('Condition = ? OR Condition = ?' => array('parameter1', 'parameter2))
     * )
     * </code>
     *
     * @param array $predicates List of predicates. These should be wrapped in an array
     * one level more than for addWhere, as query expansion is not supported here.
     * @return array List of normalised predicates
     */
    protected function normalisePredicates(array $predicates)
    {
        // Since this function is called with func_get_args we should un-nest the single first parameter
        if (count($predicates) == 1) {
            $predicates = array_shift($predicates);
        }

        // Ensure single predicates are iterable
        if (!is_array($predicates)) {
            $predicates = [$predicates];
        }

        $normalised = [];
        foreach ($predicates as $key => $value) {
            if (empty($value) && (empty($key) || is_numeric($key))) {
                continue; // Ignore empty conditions
            }
            $normalised[] = $this->parsePredicate($key, $value);
        }

        return $normalised;
    }

    /**
     * Given a list of conditions as per the format of $this->where, split
     * this into an array of predicates, and a separate array of ordered parameters
     *
     * Note, that any SQLConditionGroup objects will be evaluated here.
     * @see SQLConditionGroup
     *
     * @param array $conditions List of Conditions including parameters
     * @param array $predicates Out parameter for the list of string predicates
     * @param array $parameters Out parameter for the list of parameters
     */
    public function splitQueryParameters($conditions, &$predicates, &$parameters)
    {
        // Merge all filters with parameterized queries
        $predicates = [];
        $parameters = [];
        foreach ($conditions as $condition) {
            // Evaluate the result of SQLConditionGroup here
            if ($condition instanceof SQLConditionGroup) {
                $conditionSQL = $condition->conditionSQL($conditionParameters);
                if (!empty($conditionSQL)) {
                    $predicates[] = $conditionSQL;
                    $parameters = array_merge($parameters, $conditionParameters);
                }
            } else {
                foreach ($condition as $key => $value) {
                    $predicates[] = $key;
                    $parameters = array_merge($parameters, $value);
                }
            }
        }
    }

    /**
     * Checks whether this query is for a specific ID in a table
     *
     * @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ID=5")
     *
     * @return boolean
     */
    public function filtersOnID()
    {
        $regexp = '/^(.*\.)?("|`)?ID("|`)?\s?(=|IN)/';

        foreach ($this->getWhereParameterised($parameters) as $predicate) {
            if (preg_match($regexp, $predicate)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Checks whether this query is filtering on a foreign key, ie finding a has_many relationship
     *
     * @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ParentID=5")
     *
     * @return boolean
     */
    public function filtersOnFK()
    {
        $regexp = '/^(.*\.)?("|`)?[a-zA-Z]+ID("|`)?\s?(=|IN)/';

        // @todo - Test this works with parameterized queries
        foreach ($this->getWhereParameterised($parameters) as $predicate) {
            if (preg_match($regexp, $predicate)) {
                return true;
            }
        }

        return false;
    }

    public function isEmpty()
    {
        return empty($this->from);
    }

    /**
     * Generates an SQLDelete object using the currently specified parameters
     *
     * @return SQLDelete
     */
    public function toDelete()
    {
        $delete = new SQLDelete();
        $this->copyTo($delete);
        return $delete;
    }

    /**
     * Generates an SQLSelect object using the currently specified parameters.
     *
     * @return SQLSelect
     */
    public function toSelect()
    {
        $select = new SQLSelect();
        $this->copyTo($select);
        return $select;
    }

    /**
     * Generates an SQLUpdate object using the currently specified parameters.
     * No fields will have any assigned values for the newly generated SQLUpdate
     * object.
     *
     * @return SQLUpdate
     */
    public function toUpdate()
    {
        $update = new SQLUpdate();
        $this->copyTo($update);
        return $update;
    }
}
