<?php
/**
 * Jingga
 *
 * PHP Version 8.2
 *
 * @package   phpOMS\DataStorage\Database\Query\Grammar
 * @copyright Dennis Eichhorn
 * @license   OMS License 2.2
 * @version   1.0.0
 * @link      https://jingga.app
 */
declare(strict_types=1);

namespace phpOMS\DataStorage\Database\Query\Grammar;

use phpOMS\DataStorage\Database\BuilderAbstract;
use phpOMS\DataStorage\Database\GrammarAbstract;
use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\DataStorage\Database\Query\QueryType;

/**
 * Database query grammar.
 *
 * @package phpOMS\DataStorage\Database\Query\Grammar
 * @license OMS License 2.2
 * @link    https://jingga.app
 * @since   1.0.0
 */
class Grammar extends GrammarAbstract
{
    /**
     * {@inheritdoc}
     *
     * @throws \InvalidArgumentException
     */
    public function compileComponents(BuilderAbstract $query) : array
    {
        /** @var Builder $query */

        $sql = [];
        switch ($query->getType()) {
            case QueryType::SELECT:
                // $sql[] = $this->compileAggregate($query, $query->aggregate);
                if (!empty($query->selects)) {
                    $sql[] = $this->compileSelects($query, $query->selects);
                }

                if (!empty($query->from)) {
                    $sql[] = $this->compileFrom($query, $query->from);
                }

                if (!empty($query->joins)) {
                    $sql[] = $this->compileJoins($query, $query->joins);
                }

                if (!empty($query->wheres)) {
                    $sql[] = $this->compileWheres($query, $query->wheres);
                }

                // $sql[] = $this->compileHavings($query, $query->havings);

                if (!empty($query->groups)) {
                    $sql[] = $this->compileGroups($query, $query->groups);
                }

                if (!empty($query->orders)) {
                    $sql[] = $this->compileOrders($query, $query->orders);
                }

                if (!empty($query->limit)) {
                    $sql[] = $this->compileLimit($query, $query->limit);
                }

                if (!empty($query->offset)) {
                    $sql[] = $this->compileOffset($query, $query->offset);
                }

                if (!empty($query->unions)) {
                    $sql[] = $this->compileUnions();
                }

                if (!empty($query->lock)) {
                    $sql[] = $this->compileLock();
                }

                break;
            case QueryType::INSERT:
                if (!empty($query->into)) {
                    $sql[] = $this->compileInto($query, $query->into);
                }

                if (!empty($query->inserts)) {
                    $sql[] = $this->compileInserts($query, $query->inserts);
                }

                if (!empty($query->values)) {
                    $sql[] = $this->compileValues($query, $query->values);
                }

                break;
            case QueryType::UPDATE:
                if (!empty($query->updates)) {
                    $sql[] = $this->compileUpdates($query, $query->updates);
                }

                if (!empty($query->sets)) {
                    $sql[] = $this->compileSets($query, $query->sets);
                }

                if (!empty($query->wheres)) {
                    $sql[] = $this->compileWheres($query, $query->wheres);
                }

                break;
            case QueryType::DELETE:
                if (!empty($query->deletes)) {
                    $sql[] = $this->compileDeletes($query, $query->deletes);
                }

                if (!empty($query->from)) {
                    $sql[] = $this->compileFrom($query, $query->from);
                }

                if (!empty($query->wheres)) {
                    $sql[] = $this->compileWheres($query, $query->wheres);
                }

                break;
            case QueryType::RANDOM:
                $sql[] = $this->compileRandom($query, $query->random);

                break;
            case QueryType::RAW:
                $sql[] = $query->raw;

                break;
            case QueryType::NONE:
                return [];
            default:
                throw new \InvalidArgumentException('Unknown query type.');
        }

        return $sql;
    }

    /**
     * Compile select.
     *
     * @param Builder $query   Builder
     * @param array   $columns Columns
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileSelects(Builder $query, array $columns) : string
    {
        $expression = $this->expressionizeTableColumn($columns, $query, false);

        if ($expression === '') {
            $expression = '*';
        }

        return ($query->distinct ? 'SELECT DISTINCT ' : 'SELECT ') . $expression;
    }

    /**
     * Get random entry
     *
     * @param Builder $query   Builder
     * @param array   $columns Columns
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileRandom(Builder $query, array $columns) : string
    {
        return '';
    }

    /**
     * Create concat
     *
     * @param Concat $query   Builder
     * @param array  $columns Columns
     *
     * @return string
     *
     * @since 1.0.0
     */
    public function compileConcat(\phpOMS\DataStorage\Database\Query\Concat $query, array $columns) : string
    {
        return 'CONCAT(' . $this->expressionizeTableColumn($columns) . ') AS ' . $query->as;
    }

    /**
     * Compile select.
     *
     * @param Builder $query Builder
     * @param array   $table Table
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileUpdates(Builder $query, array $table) : string
    {
        $expression = $this->expressionizeTableColumn($table, $query);

        if ($expression === '') {
            return '';
        }

        return 'UPDATE ' . $expression;
    }

    /**
     * Compile select.
     *
     * @param Builder $query   Builder
     * @param array   $columns Columns
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileDeletes(Builder $query, array $columns) : string
    {
        return 'DELETE';
    }

    /**
     * Compile from.
     *
     * @param Builder $query Builder
     * @param array   $table Tables
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileFrom(Builder $query, array $table) : string
    {
        $expression = $this->expressionizeTableColumn($table, $query);

        if ($expression === '') {
            return '';
        }

        return 'FROM ' . $expression;
    }

    /**
     * Compile where.
     *
     * @param Builder $query  Builder
     * @param array   $wheres Where elements
     * @param bool    $first  Is first element (useful for nesting)
     *
     * @return string
     *
     * @since 1.0.0
     */
    public function compileWheres(Builder $query, array $wheres, bool $first = true) : string
    {
        $outer = '';

        foreach ($wheres as $where) {
            foreach ($where as $element) {
                $expression = '';
                $prefix     = '';

                if (!$first) {
                    $prefix = ' ' . \strtoupper($element['boolean']) . ' ';
                }

                if (\is_string($element['column'])) {
                    $expression .= $this->compileSystem($element['column']);
                } elseif ($element['column'] instanceof Builder) {
                    $expression .= '(' . \rtrim($element['column']->toSql(), ';') . ')';

                    if ($query->usePreparedStmt && $element['column']->usePreparedStmt) {
                        // If we have a subquery, we need to copy the binds over
                        // BUT we are only allowed to do this once per wheres builder
                        foreach ($element['column']->binds as $bind) {
                            $query->bind($bind);
                        }
                    }
                } elseif ($element['column'] instanceof \Closure) {
                    $expression .= $element['column']();
                }

                // Handle null for IN (...)
                // This is not allowed and must be written as (IN (...) OR IS NULL)
                $isArray = \is_array($element['value']);
                $hasNull = false;
                if ($isArray && ($key = \array_search(null, $element['value'], true)) !== false) {
                    $hasNull = true;
                    unset($element['value'][$key]);

                    if (empty($element['value'])) {
                        $element['operator'] = '=';
                        $element['value']    = null;
                    }
                }

                if (isset($element['value']) && (!empty($element['value']) || !$isArray)) {
                    if ($isArray && \count($element['value']) === 1) {
                        $element['value']    = \reset($element['value']);
                        $element['operator'] = '=';
                    }

                    $expression .= ' ' . \strtoupper($element['operator']) . ' ' . $this->compileValue($query, $element['value']);

                    if ($hasNull) {
                        $expression = '(' . $expression . ' OR ' . $this->compileSystem($element['column']) . ' IS NULL)';
                    }
                } elseif ($element['value'] === null && !($element['column'] instanceof Builder)) {
                    $operator = $element['operator'] === '=' ? 'IS' : 'IS NOT';
                    $expression .= ' ' . $operator . ' ' . $this->compileValue($query, $element['value']);
                }

                $outer .= $prefix . $expression;
                $first = false;
            }
        }

        if ($outer === '') {
            return '';
        }

        return 'WHERE ' . $outer;
    }

    /**
     * Compile limit.
     *
     * @param Builder $query Builder
     * @param int     $limit Limit
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileLimit(Builder $query, int $limit) : string
    {
        if ($query->usePreparedStmt) {
            $query->bind([
                'value' => $limit,
                'type'  => \PDO::PARAM_INT,
            ]);

            $limit = '?';
        }

        return 'LIMIT ' . $limit;
    }

    /**
     * Compile offset.
     *
     * @param Builder $query  Builder
     * @param int     $offset Offset
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileOffset(Builder $query, int $offset) : string
    {
        if ($query->usePreparedStmt) {
            $query->bind([
                'value' => $offset,
                'type'  => \PDO::PARAM_INT,
            ]);

            $offset = '?';
        }

        return 'OFFSET ' . $offset;
    }

    /**
     * Compile joins.
     *
     * @param Builder $query Builder
     * @param array   $joins Joins
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileJoins(Builder $query, array $joins) : string
    {
        $expression = '';

        foreach ($joins as $table => $join) {
            $expression .= $join['type'] . ' ';

            if (\is_string($join['table'])) {
                $expression .= $this->compileSystem($join['table']) . (\is_string($join['alias']) ? ' as ' . $join['alias'] : '');
            } elseif ($join['table'] instanceof \Closure) {
                $expression .= $join['table']() . (\is_string($join['alias']) ? ' as ' . $join['alias'] : '');
            } elseif ($join['table'] instanceof Builder) {
                $expression .= '(' . \rtrim($join['table']->toSql(), ';') . ')' . (\is_string($join['alias']) ? ' as ' . $join['alias'] : '');

                if ($query->usePreparedStmt && $join['table']->usePreparedStmt) {
                    // If we have a subquery, we need to copy the binds over
                    foreach ($join['table']->binds as $bind) {
                        $query->bind($bind);
                    }
                }
            }

            $expression .= $this->compileOn($query, $query->ons[$join['alias'] ?? $table]) . ' ';
        }

        return \rtrim($expression, ', ');
    }

    /**
     * Compile on.
     *
     * @param Builder $query Builder
     * @param array   $ons   On values
     * @param bool    $first Is first on element
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileOn(Builder $query, array $ons, bool $first = true) : string
    {
        $expression = '';

        foreach ($ons as $on) {
            $expression .= $this->compileOnElement($on, $query, $first);
            $first = false;
        }

        if ($expression === '') {
            return '';
        }

        return ' ON ' . $expression;
    }

    /**
     * Compile where element.
     *
     * @param array   $element Element data
     * @param Builder $query   Query builder
     * @param bool    $first   Is first element (useful for nesting)
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileOnElement(array $element, Builder $query, bool $first = true) : string
    {
        $expression = '';

        if (!$first) {
            $expression = ' ' . \strtoupper($element['boolean']) . ' ';
        }

        if (\is_string($element['column'])) {
            // @bug Handle bug when no table is specified in the where column
            if (\count($query->from) === 1 && \stripos($element['column'], '.') === false) {
                $element['column'] = $query->from[0] . '.' . $element['column'];
            }

            $expression .= $this->compileSystem($element['column']);
        } elseif ($element['column'] instanceof Builder) {
            $expression .= '(' . $element['column']->toSql() . ')';

            if ($query->usePreparedStmt && $element['column']->usePreparedStmt) {
                // If we have a subquery, we need to copy the binds over
                foreach ($element['column']->binds as $bind) {
                    $query->bind($bind);
                }
            }
        } elseif ($element['column'] instanceof \Closure) {
            $expression .= $element['column']();
        }

        // @bug The on part of a join doesn't allow string values because they conflict with column name
        //      Other data types are possible because they don't conflict with the data type of columns (string)
        //      Consider to create a ColumnName() class or maybe StringValue() since we use string values less often.
        //      https://github.com/Karaka-Management/phpOMS/issues/369
        if (isset($element['value'])) {
            $expression .= ' ' . \strtoupper($element['operator']) . ' '
                . (\is_string($element['value']) ? $this->compileSystem($element['value']) : $element['value']);
        } else {
            $operator = $element['operator'] === '=' ? 'IS' : 'IS NOT';
            $expression .= ' ' . $operator . ' ' . $this->compileValue($query, $element['value']);
        }

        return $expression;
    }

    /**
     * Compile offset.
     *
     * @param Builder $query  Builder
     * @param array   $groups Groups
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileGroups(Builder $query, array $groups) : string
    {
        $expression = '';

        foreach ($groups as $group) {
            $expression .= $this->compileSystem($group) . ', ';
        }

        $expression = \rtrim($expression, ', ');

        return 'GROUP BY ' . $expression;
    }

    /**
     * Compile offset.
     *
     * @param Builder $query  Builder
     * @param array   $orders Order
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileOrders(Builder $query, array $orders) : string
    {
        $expression = '';

        foreach ($orders as $column => $order) {
            $expression .= $this->compileSystem($column) . ' ' . $order . ', ';
        }

        $expression = \rtrim($expression, ', ');

        if ($expression === '') {
            return '';
        }

        return 'ORDER BY ' . $expression;
    }

    /**
     * Compile unions.
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileUnions() : string
    {
        return '';
    }

    /**
     * Compile lock.
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileLock() : string
    {
        return '';
    }

    /**
     * Compile insert into table.
     *
     * @param Builder $query Builder
     * @param string  $table Table
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileInto(Builder $query, string $table) : string
    {
        return 'INSERT INTO ' . $this->compileSystem($table);
    }

    /**
     * Compile insert into columns.
     *
     * @param Builder $query   Builder
     * @param array   $columns Columns
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileInserts(Builder $query, array $columns) : string
    {
        $count = \count($columns) - 1;

        if ($count === -1) {
            return '';
        }

        $cols = '(';
        for ($i = 0; $i < $count; ++$i) {
            $cols .= $this->compileSystem($columns[$i]) . ', ';
        }

        return $cols .= $this->compileSystem($columns[$count]) . ')';
    }

    /**
     * Compile insert values.
     *
     * @param Builder $query  Builder
     * @param array   $values Values
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileValues(Builder $query, array $values) : string
    {
        $values = \array_values($values);
        $count  = \count($values) - 1;
        if ($count === -1) {
            return '';
        }

        $vals = 'VALUES ';
        for ($i = 0; $i < $count; ++$i) {
            $vals .= $this->compileValue($query, $values[$i]) . ', ';
        }

        return $vals . $this->compileValue($query, $values[$count]);
    }

    /**
     * Compile insert values.
     *
     * @param Builder $query  Builder
     * @param array   $values Values
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function compileSets(Builder $query, array $values) : string
    {
        $vals = '';

        foreach ($values as $column => $value) {
            $expression = $this->expressionizeTableColumn([$column], $query, false);

            $vals .= $expression . ' = ' . $this->compileValue($query, $value) . ', ';
        }

        if ($vals === '') {
            return '';
        }

        return 'SET ' . \rtrim($vals, ', ');
    }
}
