<?php

namespace Openguru\OpenCartFramework\QueryBuilder;

use Closure;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\QueryBuilder\Grammars\Grammar;
use Openguru\OpenCartFramework\Support\Utils;
use InvalidArgumentException;

class Builder
{
    public const JOIN_TYPE_LEFT = 'LEFT';
    public const JOIN_TYPE_INNER = 'INNER';
    public const JOIN_TYPE_RIGHT = 'RIGHT';

    private $connection;
    private $grammar;

    public $columns = '*';
    public $from;
    public $joins = [];
    public $wheres = [];
    public $orders = [];
    public $groupBy = [];
    public $limit;
    public $offset;
    public $distinct = false;

    public $bindings = [
        'select' => [],
        'from' => [],
        'join' => [],
        'where' => [],
        'order' => [],
    ];

    public function __construct(ConnectionInterface $connection, Grammar $grammar)
    {
        $this->connection = $connection;
        $this->grammar = $grammar;
    }

    public function newQuery(): Builder
    {
        return new self($this->getConnection(), $this->getGrammar());
    }

    public function select(array $columns): Builder
    {
        $this->columns = [];
        $this->bindings['select'] = [];

        if (! $columns) {
            throw new InvalidArgumentException('Select columns not provided');
        }

        foreach ($columns as $key => $value) {
            if ($value instanceof RawExpression) {
                $this->columns[] = $value;
                continue;
            }

            if (is_string($key)) {
                $this->columns[] = [
                    'column' => $key,
                    'as' => $value,
                ];
                continue;
            }

            $this->columns[] = [
                'column' => $value,
                'as' => null,
            ];
        }

        return $this;
    }

    public function from(string $table, ?string $as = null): Builder
    {
        $this->from = compact('table', 'as');

        return $this;
    }

    public function whereNull($column, $boolean = 'and'): Builder
    {
        $this->wheres[] = [
            'type' => 'Null',
            'column' => $column,
            'boolean' => $boolean,
        ];

        return $this;
    }

    public function whereNotNull($column, $boolean = 'and'): Builder
    {
        $this->wheres[] = [
            'type' => 'NotNull',
            'column' => $column,
            'boolean' => $boolean,
        ];

        return $this;
    }

    public function whereNested(Closure $callback, $boolean = 'and'): Builder
    {
        $callback($nestedQuery = $this->newQuery());

        $this->wheres[] = [
            'type' => 'Nested',
            'query' => $nestedQuery,
            'boolean' => $boolean,
        ];

        $this->bindings['where'] = array_merge(
            $this->bindings['where'],
            $nestedQuery->getBindings('where')
        );

        return $this;
    }

    public function whereRaw(string $expression, $boolean = 'and'): Builder
    {
        $this->wheres[] = [
            'type' => 'Raw',
            'expression' => $expression,
            'boolean' => $boolean,
        ];

        return $this;
    }

    public function where($column, $operator, $value, $boolean = 'and'): Builder
    {
        $this->wheres[] = [
            'type' => 'Basic',
            'column' => $column,
            'operator' => $operator,
            'value' => $value,
            'boolean' => $boolean,
        ];

        $this->addBinding($value);

        return $this;
    }

    public function whereIn($column, array $values, $not = false, $boolean = 'and'): Builder
    {
        $this->wheres[] = [
            'type' => 'In',
            'column' => $column,
            'operator' => $not ? 'NOT IN' : 'IN',
            'value' => $values,
            'boolean' => $boolean,
        ];

        $this->addBinding($values);

        return $this;
    }

    public function whereNotIn($column, array $values, $boolean = 'and'): Builder
    {
        $this->wheres[] = [
            'type' => 'In',
            'column' => $column,
            'operator' => 'NOT IN',
            'value' => $values,
            'boolean' => $boolean,
        ];

        $this->addBinding($values);

        return $this;
    }

    /**
     * @param RawExpression|string $column
     * @param array $values
     * @param string $boolean
     * @return $this
     */
    public function whereBetween($column, array $values, string $boolean = 'and'): Builder
    {
        if (count($values) !== 2) {
            throw new InvalidArgumentException('Invalid number of values provided.');
        }

        $this->wheres[] = [
            'type' => 'Between',
            'column' => $column,
            'values' => $values,
            'boolean' => $boolean,
        ];

        $this->addBinding($values[0]);
        $this->addBinding($values[1]);

        return $this;
    }

    public function orWhere($column, $operator, $value): Builder
    {
        return $this->where($column, $operator, $value, 'or');
    }

    public function limit(int $limit): Builder
    {
        $this->limit = $limit;

        return $this;
    }

    public function offset(int $offset): Builder
    {
        $this->offset = $offset;

        return $this;
    }

    public function forPage(int $page = 0, int $perPage = 10): Builder
    {
        $offset = ($page - 1) * $perPage;

        return $this->offset($offset)->limit($perPage);
    }

    public function orderBy(string $column, string $direction = 'ASC'): Builder
    {
        $direction = strtoupper($direction);

        if (! in_array($direction, ['ASC', 'DESC'], true)) {
            throw new InvalidArgumentException('Order direction must be "ASC" or "DESC".');
        }

        $this->orders[] = [
            'column' => $column,
            'direction' => $direction,
        ];

        return $this;
    }

    public function toSql(): string
    {
        return $this->grammar->compileComponents($this);
    }

    public function toRawSql(): string
    {
        return $this->substituteBindingsIntoRawSql(
            str_replace("\n", '  ', $this->toSql()),
            $this->getBindings()
        );
    }

    public function substituteBindingsIntoRawSql(string $sql, array $bindings): string
    {
        $bindings = array_map(function ($value) {
            return $this->connection->escape($value);
        }, $bindings);

        $query = '';

        $isStringLiteral = false;

        $sqlLen = strlen($sql);

        for ($i = 0; $i < $sqlLen; $i++) {
            $char = $sql[$i];
            $nextChar = $sql[$i + 1] ?? null;

            // Single quotes can be escaped as '' according to the SQL standard while
            // MySQL uses \'. Postgres has operators like ?| that must get encoded
            // in PHP like ??|. We should skip over the escaped characters here.
            if (in_array($char . $nextChar, ["\'", "''", '??'])) {
                $query .= $char . $nextChar;
                ++$i;
            } elseif ($char === "'") { // Starting / leaving string literal...
                $query .= $char;
                $isStringLiteral = ! $isStringLiteral;
            } elseif ($char === '?' && ! $isStringLiteral) { // Substitutable binding...
                $query .= array_shift($bindings) ?? '?';
            } else { // Normal character...
                $query .= $char;
            }
        }

        return $query;
    }

    public function get(): array
    {
        return $this->runSelect();
    }

    /**
     * @return array|null
     */
    public function firstOrNull(): ?array
    {
        $rows = $this->limit(1)->get();

        return $rows[0] ?? null;
    }

    public function value(string $field)
    {
        $rows = $this->limit(1)->get();

        return $rows[0][$field] ?? null;
    }

    public function pluck(string $column, string $index = null): array
    {
        $items = $this->runSelect();

        return array_column($items, $column, $index);
    }

    public function storeResultsToTable(string $destinationTable): bool
    {
        $sql = "INSERT INTO `$destinationTable` {$this->toSql()}";

        $this->connection->truncateTable($destinationTable);

        return $this->connection->statement($sql, $this->getBindings());
    }

    public function runSelect(): array
    {
        return $this->connection->select(
            $this->toSql(),
            $this->getBindings()
        );
    }

    public function getBindings(string $type = null): array
    {
        if ($type === null) {
            return Utils::arrayFlatten($this->bindings);
        }

        return $this->bindings[$type];
    }

    public function addBinding($value, $type = 'where'): Builder
    {
        if (! array_key_exists($type, $this->bindings)) {
            throw new InvalidArgumentException("Invalid binding type: {$type}.");
        }

        $this->bindings[$type][] = $value;

        return $this;
    }

    public function dd(): void
    {
        Utils::dd($this->toSql(), $this->getBindings());
    }

    /**
     * @param Table|string $table
     * @param Closure $joinClosure
     * @param $type
     * @return $this
     */
    public function join($table, Closure $joinClosure, $type = self::JOIN_TYPE_INNER): Builder
    {
        $joinClause = new JoinClause($this, $type, (string) $table);

        $joinClosure($joinClause);

        $this->joins[] = $joinClause;
        $this->bindings['join'][] = $joinClause->getBindings();

        return $this;
    }

    /**
     * @param Table|string $table
     * @param Closure $joinClosure
     * @return $this
     */
    public function leftJoin($table, Closure $joinClosure): Builder
    {
        return $this->join($table, $joinClosure, self::JOIN_TYPE_LEFT);
    }

    public function getConnection(): ConnectionInterface
    {
        return $this->connection;
    }

    public function getGrammar(): Grammar
    {
        return $this->grammar;
    }

    public function count(): int
    {
        $sql = 'SELECT COUNT(*) AS total_count FROM (' . $this->toSql() . ') AS x';

        $result = $this->connection->select($sql, $this->getBindings());

        return $result[0]['total_count'] ?? 0;
    }

    public function distinct(): Builder
    {
        $this->distinct = true;

        return $this;
    }

    /**
     * Apply a callback to the query if the given condition is true.
     *
     * @param bool $condition
     * @param Closure $callback
     * @param Closure|null $default
     * @return $this
     */
    public function when(bool $condition, Closure $callback, Closure $default = null): Builder
    {
        if ($condition) {
            $callback($this);
        } elseif ($default) {
            $default($this);
        }

        return $this;
    }

    public function hasJoinAlias(string $joinAlias): bool
    {
        $join = array_filter($this->joins, static function (JoinClause $join) use ($joinAlias) {
            return strpos($join->table, $joinAlias) !== false;
        });

        return count($join) > 0;
    }

    public function groupBy(array $columns): Builder
    {
        $this->groupBy = $columns;

        return $this;
    }

    public function update(string $table, array $values): bool
    {
        $sql = $this->grammar->compileUpdate($this, $table, $values);

        $bindings = array_merge(
            Utils::arrayFlatten($this->getBindings('join')),
            array_values($values),
            Utils::arrayFlatten($this->getBindings('where'))
        );

        return $this->connection->statement($sql, $bindings);
    }
}
