<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
namespace Magento\Framework\DB;

use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\DB\Sql\Expression;

/**
 * Class for SQL SELECT generation and results.
 *
 * @api
 * @method \Magento\Framework\DB\Select from($name, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select join($name, $cond, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select joinInner($name, $cond, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select joinLeft($name, $cond, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select joinNatural($name, $cond, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select joinFull($name, $cond, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select joinRight($name, $cond, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select joinCross($name, $cols = '*', $schema = null)
 * @method \Magento\Framework\DB\Select orWhere($cond, $value = null, $type = null)
 * @method \Magento\Framework\DB\Select group($spec)
 * @method \Magento\Framework\DB\Select order($spec)
 * @method \Magento\Framework\DB\Select limitPage($page, $rowCount)
 * @method \Magento\Framework\DB\Select forUpdate($flag = true)
 * @method \Magento\Framework\DB\Select distinct($flag = true)
 * @method \Magento\Framework\DB\Select reset($part = null)
 * @method \Magento\Framework\DB\Select columns($cols = '*', $correlationName = null)
 * @since 100.0.2
 */
class Select extends \Zend_Db_Select
{
    /**
     * Condition type
     */
    public const TYPE_CONDITION = 'TYPE_CONDITION';

    /**
     * Straight join key
     */
    public const STRAIGHT_JOIN = 'straightjoin';

    /**
     * Straight join SQL directive.
     */
    public const SQL_STRAIGHT_JOIN = 'STRAIGHT_JOIN';

    /**
     * @var Select\SelectRenderer
     */
    private $selectRenderer;

    /**
     * Class constructor
     * Add straight join support
     *
     * @param Adapter\Pdo\Mysql $adapter
     * @param Select\SelectRenderer $selectRenderer
     * @param array $parts
     */
    public function __construct(
        \Magento\Framework\DB\Adapter\Pdo\Mysql $adapter,
        \Magento\Framework\DB\Select\SelectRenderer $selectRenderer,
        $parts = []
    ) {
        self::$_partsInit = array_merge(self::$_partsInit, $parts);
        if (!isset(self::$_partsInit[self::STRAIGHT_JOIN])) {
            self::$_partsInit = [self::STRAIGHT_JOIN => false] + self::$_partsInit;
        }

        $this->selectRenderer = $selectRenderer;
        parent::__construct($adapter);
    }

    /**
     * Adds a WHERE condition to the query by AND.
     *
     * If a value is passed as the second param, it will be quoted
     * and replaced into the condition wherever a question-mark
     * appears. Array values are quoted and comma-separated.
     *
     * <code>
     * // simplest but non-secure
     * $select->where("id = $id");
     *
     * // secure (ID is quoted but matched anyway)
     * $select->where('id = ?', $id);
     *
     * // alternatively, with named binding
     * $select->where('id = :id');
     * </code>
     *
     * You may also construct IN statements:
     *
     * <code>
     * $select->where('entity_id IN (?)', ['1', '2', '3']);
     * </code>
     *
     * Note that it is more correct to use named bindings in your
     * queries for values other than strings. When you use named
     * bindings, don't forget to pass the values when actually
     * making a query:
     *
     * <code>
     * $db->fetchAll($select, array('id' => 5));
     * </code>
     *
     * @param string $cond The WHERE condition.
     * @param array|null|int|string|float|Expression|Select|\DateTimeInterface $value The value to quote.
     * @param int|string|null $type OPTIONAL SQL datatype of the given value e.g. Zend_Db::FLOAT_TYPE or "INT"
     * @return \Magento\Framework\DB\Select
     */
    public function where($cond, $value = null, $type = null)
    {
        if ($value === null && $type === null) {
            $value = '';
        } elseif ((string)$type === self::TYPE_CONDITION) {
            $type = null;
        }
        if (is_array($value)) {
            $cond = $this->getConnection()->quoteInto($cond, $value, $type);
            $value = null;
        }
        return parent::where($cond, $value, $type);
    }

    /**
     * Reset unused LEFT JOIN(s)
     *
     * @return $this
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    public function resetJoinLeft()
    {
        foreach ($this->_parts[self::FROM] as $tableId => $tableProp) {
            if ($tableProp['joinType'] == self::LEFT_JOIN) {
                $useJoin = false;
                foreach ($this->_parts[self::COLUMNS] as $columnEntry) {
                    list($correlationName, $column) = $columnEntry;
                    if ($column instanceof \Zend_Db_Expr) {
                        if ($this->_findTableInCond(
                            $tableId,
                            $column
                        ) || $this->_findTableInCond(
                            $tableProp['tableName'],
                            $column
                        )
                        ) {
                            $useJoin = true;
                        }
                    } else {
                        if ($correlationName == $tableId) {
                            $useJoin = true;
                        }
                    }
                }
                foreach ($this->_parts[self::WHERE] as $where) {
                    if ($this->_findTableInCond(
                        $tableId,
                        $where
                    ) || $this->_findTableInCond(
                        $tableProp['tableName'],
                        $where
                    )
                    ) {
                        $useJoin = true;
                    }
                }

                $joinUseInCond = $useJoin;
                $joinInTables = [];

                foreach ($this->_parts[self::FROM] as $tableCorrelationName => $table) {
                    if ($tableCorrelationName == $tableId) {
                        continue;
                    }
                    if (!empty($table['joinCondition'])) {
                        if ($this->_findTableInCond(
                            $tableId,
                            $table['joinCondition']
                        ) || $this->_findTableInCond(
                            $tableProp['tableName'],
                            $table['joinCondition']
                        )
                        ) {
                            $useJoin = true;
                            $joinInTables[] = $tableCorrelationName;
                        }
                    }
                }

                if (!$useJoin) {
                    unset($this->_parts[self::FROM][$tableId]);
                } else {
                    $this->_parts[self::FROM][$tableId]['useInCond'] = $joinUseInCond;
                    $this->_parts[self::FROM][$tableId]['joinInTables'] = $joinInTables;
                }
            }
        }

        $this->_resetJoinLeft();

        return $this;
    }

    /**
     * Validate LEFT joins, and remove it if not exists
     *
     * @return $this
     */
    protected function _resetJoinLeft()
    {
        foreach ($this->_parts[self::FROM] as $tableId => $tableProp) {
            if ($tableProp['joinType'] == self::LEFT_JOIN) {
                if ($tableProp['useInCond']) {
                    continue;
                }

                $used = false;
                foreach ($tableProp['joinInTables'] as $table) {
                    if (isset($this->_parts[self::FROM][$table])) {
                        $used = true;
                        break;
                    }
                }

                if (!$used) {
                    unset($this->_parts[self::FROM][$tableId]);
                    return $this->_resetJoinLeft();
                }
            }
        }

        return $this;
    }

    /**
     * Find table name in condition (where, column)
     *
     * @param string $table
     * @param string $cond
     * @return bool
     */
    protected function _findTableInCond($table, $cond)
    {
        $quote = $this->_adapter->getQuoteIdentifierSymbol();
        $cond = (string)$cond;
        $table = (string)$table;
        if (strpos($cond, $quote . $table . $quote . '.') !== false) {
            return true;
        }

        $position = 0;
        $result = 0;
        $needle = [];
        while (is_integer($result)) {
            $result = strpos($cond, $table . '.', $position);

            if (is_integer($result)) {
                $needle[] = $result;
                $position = $result + strlen($table) + 1;
            }
        }

        if (!$needle) {
            return false;
        }

        foreach ($needle as $position) {
            if ($position == 0) {
                return true;
            }
            if (!preg_match('#[a-z0-9_]#is', substr($cond, $position - 1, 1))) {
                return true;
            }
        }

        return false;
    }

    /**
     * Populate the {@link $_parts} 'join' key
     *
     * Does the dirty work of populating the join key.
     *
     * The $name and $cols parameters follow the same logic
     * as described in the from() method.
     *
     * @param  null|string $type Type of join; inner, left, and null are currently supported
     * @param  array|string|\Zend_Db_Expr $name Table name
     * @param  string $cond Join on this condition
     * @param  array|string $cols The columns to select from the joined table
     * @param  string $schema The database name to specify, if any.
     * @return \Magento\Framework\DB\Select This \Magento\Framework\DB\Select object
     * @throws \Zend_Db_Select_Exception
     */
    protected function _join($type, $name, $cond, $cols, $schema = null)
    {
        if ($type == self::INNER_JOIN && empty($cond)) {
            $type = self::CROSS_JOIN;
        }
        return parent::_join($type, $name, $cond, $cols, $schema);
    }

    /**
     * Sets a limit count and offset to the query.
     *
     * @param int $count OPTIONAL The number of rows to return.
     * @param int $offset OPTIONAL Start returning after this many rows.
     * @return $this
     */
    public function limit($count = null, $offset = null)
    {
        if ($count === null) {
            $this->reset(self::LIMIT_COUNT);
        } else {
            $this->_parts[self::LIMIT_COUNT] = (int)$count;
        }
        if ($offset === null) {
            $this->reset(self::LIMIT_OFFSET);
        } else {
            $this->_parts[self::LIMIT_OFFSET] = (int)$offset;
        }
        return $this;
    }

    /**
     * Cross Table Update From Current select
     *
     * @param string|array $table
     * @return string
     */
    public function crossUpdateFromSelect($table)
    {
        return $this->getConnection()->updateFromSelect($this, $table);
    }

    /**
     * Insert to table from current select
     *
     * @param string $tableName
     * @param array $fields
     * @param bool $onDuplicate
     * @return string
     */
    public function insertFromSelect($tableName, $fields = [], $onDuplicate = true)
    {
        $mode = $onDuplicate ? AdapterInterface::INSERT_ON_DUPLICATE : false;
        return $this->getConnection()->insertFromSelect($this, $tableName, $fields, $mode);
    }

    /**
     * Generate INSERT IGNORE query to the table from current select
     *
     * @param string $tableName
     * @param array $fields
     * @return string
     */
    public function insertIgnoreFromSelect($tableName, $fields = [])
    {
        return $this->getConnection()->insertFromSelect($this, $tableName, $fields, AdapterInterface::INSERT_IGNORE);
    }

    /**
     * Retrieve DELETE query from select
     *
     * @param string $table The table name or alias
     * @return string
     */
    public function deleteFromSelect($table)
    {
        return $this->getConnection()->deleteFromSelect($this, $table);
    }

    /**
     * Modify (hack) part of the structured information for the current query
     *
     * @param string $part
     * @param mixed $value
     * @return $this
     * @throws \Zend_Db_Select_Exception
     */
    public function setPart($part, $value)
    {
        $part = $part !== null ? strtolower($part) : '';
        if (!array_key_exists($part, $this->_parts)) {
            throw new \Zend_Db_Select_Exception("Invalid Select part '{$part}'");
        }
        $this->_parts[$part] = $value;
        return $this;
    }

    /**
     * Use a STRAIGHT_JOIN for the SQL Select
     *
     * @param bool $flag Whether or not the SELECT use STRAIGHT_JOIN (default true).
     * @return $this
     */
    public function useStraightJoin($flag = true)
    {
        $this->_parts[self::STRAIGHT_JOIN] = (bool)$flag;
        return $this;
    }

    /**
     * Render STRAIGHT_JOIN clause
     *
     * @param string $sql SQL query
     * @return string
     */
    protected function _renderStraightjoin($sql)
    {
        if ($this->_adapter->supportStraightJoin() && !empty($this->_parts[self::STRAIGHT_JOIN])) {
            $sql .= ' ' . self::SQL_STRAIGHT_JOIN;
        }

        return $sql;
    }

    /**
     * Adds to the internal table-to-column mapping array.
     *
     * @param  string $correlationName The table/join the columns come from.
     * @param  array|string $cols The list of columns; preferably as an array,
     *     but possibly as a string containing one column.
     * @param  bool|string $afterCorrelationName True if it should be prepended,
     *     a correlation name if it should be inserted
     * @return void
     */
    protected function _tableCols($correlationName, $cols, $afterCorrelationName = null)
    {
        if (!is_array($cols)) {
            $cols = [$cols];
        }

        foreach ($cols as $k => $v) {
            if ($v instanceof Select) {
                $cols[$k] = new \Zend_Db_Expr(sprintf('(%s)', $v->assemble()));
            }
        }

        parent::_tableCols($correlationName, $cols, $afterCorrelationName);
    }

    /**
     * Adds the random order to query
     *
     * @param string $field     integer field name
     * @return $this
     */
    public function orderRand($field = null)
    {
        $this->_adapter->orderRand($this, $field);
        return $this;
    }

    /**
     * Render FOR UPDATE clause
     *
     * @param string $sql SQL query
     * @return string
     */
    protected function _renderForupdate($sql)
    {
        if ($this->_parts[self::FOR_UPDATE]) {
            $sql = $this->_adapter->forUpdate($sql);
        }

        return $sql;
    }

    /**
     * Add EXISTS clause
     *
     * @param Select $select
     * @param string $joinCondition
     * @param bool $isExists
     * @return $this
     */
    public function exists($select, $joinCondition, $isExists = true)
    {
        if ($isExists) {
            $exists = 'EXISTS (%s)';
        } else {
            $exists = 'NOT EXISTS (%s)';
        }
        $select->reset(self::COLUMNS)->columns([new \Zend_Db_Expr('1')])->where($joinCondition);

        $exists = sprintf($exists, $select->assemble());

        $this->where($exists);
        return $this;
    }

    /**
     * Get adapter
     *
     * @return \Magento\Framework\DB\Adapter\AdapterInterface
     */
    public function getConnection()
    {
        return $this->_adapter;
    }

    /**
     * Converts this object to an SQL SELECT string.
     *
     * @return string|null This object as a SELECT string. (or null if a string cannot be produced.)
     * @since 100.1.0
     */
    public function assemble()
    {
        return $this->selectRenderer->render($this);
    }

    /**
     * Remove links to other objects.
     *
     * @return string[]
     * @since 100.0.11
     */
    public function __sleep()
    {
        $properties = array_keys(get_object_vars($this));
        $properties = array_diff(
            $properties,
            [
                '_adapter',
                'selectRenderer'
            ]
        );
        return $properties;
    }

    /**
     * Init not serializable fields
     *
     * @return void
     * @since 100.0.11
     */
    public function __wakeup()
    {
        $objectManager = \Magento\Framework\App\ObjectManager::getInstance();
        $this->_adapter = $objectManager->get(ResourceConnection::class)->getConnection();
        $this->selectRenderer = $objectManager->get(\Magento\Framework\DB\Select\SelectRenderer::class);
    }
}
