<?php

namespace BaoPham\DynamoDb;

use BaoPham\DynamoDb\Concerns\HasParsers;
use BaoPham\DynamoDb\ConditionAnalyzer\Analyzer;
use BaoPham\DynamoDb\Facades\DynamoDb;
use BaoPham\DynamoDb\H;
use Closure;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Arr;

class DynamoDbQueryBuilder
{
    use HasParsers;

    const MAX_LIMIT = -1;
    const DEFAULT_TO_ITERATOR = true;

    /**
     * The maximum number of records to return.
     *
     * @var int
     */
    public $limit;

    /**
     * @var array
     */
    public $wheres = [];

    /**
     * @var DynamoDbModel
     */
    protected $model;

    /**
     * @var \Aws\DynamoDb\DynamoDbClient
     */
    protected $client;

    /**
     * @var Closure
     */
    protected $decorator;

    /**
     * Applied global scopes.
     *
     * @var array
     */
    protected $scopes = [];

    /**
     * Removed global scopes.
     *
     * @var array
     */
    protected $removedScopes = [];

    /**
     * When not using the iterator, you can store the lastEvaluatedKey to
     * paginate through the results. The getAll method will take this into account
     * when used with $use_iterator = false.
     *
     * @var mixed
     */
    protected $lastEvaluatedKey;

    /**
     * Specified index name for the query.
     *
     * @var string
     */
    protected $index;

    public function __construct(DynamoDbModel $model)
    {
        $this->model = $model;
        $this->client = $model->getClient();
        $this->setupExpressions();
    }

    /**
     * Alias to set the "limit" value of the query.
     *
     * @param  int  $value
     * @return DynamoDbQueryBuilder
     */
    public function take($value)
    {
        return $this->limit($value);
    }

    /**
     * Set the "limit" value of the query.
     *
     * @param  int  $value
     * @return $this
     */
    public function limit($value)
    {
        $this->limit = $value;

        return $this;
    }

    /**
     * Alias to set the "offset" value of the query.
     *
     * @param  int $value
     * @throws NotSupportedException
     */
    public function skip($value)
    {
        return $this->offset($value);
    }

    /**
     * Set the "offset" value of the query.
     *
     * @param  int $value
     * @throws NotSupportedException
     */
    public function offset($value)
    {
        throw new NotSupportedException('Skip/Offset is not supported. Consider using after() instead');
    }

    /**
     * Determine the starting point (exclusively) of the query.
     * Unfortunately, offset of how many records to skip does not make sense for DynamoDb.
     * Instead, provide the last result of the previous query as the starting point for the next query.
     *
     * @param  DynamoDbModel|null  $after
     *   Examples:
     *
     *   For query such as
     *       $query = $model->where('count', 10)->limit(2);
     *       $last = $query->all()->last();
     *   Take the last item of this query result as the next "offset":
     *       $nextPage = $query->after($last)->limit(2)->all();
     *
     *   Alternatively, pass in nothing to reset the starting point.
     *
     * @return $this
     */
    public function after(DynamoDbModel $after = null)
    {
        if (empty($after)) {
            $this->lastEvaluatedKey = null;

            return $this;
        }

        $afterKey = $after->getKeys();

        $analyzer = $this->getConditionAnalyzer();

        if ($index = $analyzer->index()) {
            foreach ($index->columns() as $column) {
                $afterKey[$column] = $after->getAttribute($column);
            }
        }

        $this->lastEvaluatedKey = DynamoDb::marshalItem($afterKey);

        return $this;
    }

    /**
     * Similar to after(), but instead of using the model instance, the model's keys are used.
     * Use $collection->lastKey() or $model->getKeys() to retrieve the value.
     *
     * @param  Array  $key
     *   Examples:
     *
     *   For query such as
     *       $query = $model->where('count', 10)->limit(2);
     *       $items = $query->all();
     *   Take the last item of this query result as the next "offset":
     *       $nextPage = $query->afterKey($items->lastKey())->limit(2)->all();
     *
     *   Alternatively, pass in nothing to reset the starting point.
     *
     * @return $this
     */
    public function afterKey($key = null)
    {
        $this->lastEvaluatedKey = empty($key) ? null : DynamoDb::marshalItem($key);
        return $this;
    }

    /**
     * Set the index name manually
     *
     * @param string $index The index name
     * @return $this
     */
    public function withIndex($index)
    {
        $this->index = $index;
        return $this;
    }

    public function where($column, $operator = null, $value = null, $boolean = 'and')
    {
        // If the column is an array, we will assume it is an array of key-value pairs
        // and can add them each as a where clause. We will maintain the boolean we
        // received when the method was called and pass it into the nested where.
        if (is_array($column)) {
            foreach ($column as $key => $value) {
                $this->where($key, '=', $value, $boolean);
            }

            return $this;
        }

        // Here we will make some assumptions about the operator. If only 2 values are
        // passed to the method, we will assume that the operator is an equals sign
        // and keep going. Otherwise, we'll require the operator to be passed in.
        if (func_num_args() == 2) {
            list($value, $operator) = [$operator, '='];
        }

        // If the columns is actually a Closure instance, we will assume the developer
        // wants to begin a nested where statement which is wrapped in parenthesis.
        // We'll add that Closure to the query then return back out immediately.
        if ($column instanceof Closure) {
            return $this->whereNested($column, $boolean);
        }

        // If the given operator is not found in the list of valid operators we will
        // assume that the developer is just short-cutting the '=' operators and
        // we will set the operators to '=' and set the values appropriately.
        if (!ComparisonOperator::isValidOperator($operator)) {
            list($value, $operator) = [$operator, '='];
        }

        // If the value is a Closure, it means the developer is performing an entire
        // sub-select within the query and we will need to compile the sub-select
        // within the where clause to get the appropriate query record results.
        if ($value instanceof Closure) {
            throw new NotSupportedException('Closure in where clause is not supported');
        }

        $this->wheres[] = [
            'column' => $column,
            'type' => ComparisonOperator::getDynamoDbOperator($operator),
            'value' => $value,
            'boolean' => $boolean,
        ];

        return $this;
    }

    /**
     * Add a nested where statement to the query.
     *
     * @param  \Closure $callback
     * @param  string   $boolean
     * @return $this
     */
    public function whereNested(Closure $callback, $boolean = 'and')
    {
        call_user_func($callback, $query = $this->forNestedWhere());

        return $this->addNestedWhereQuery($query, $boolean);
    }

    /**
     * Create a new query instance for nested where condition.
     *
     * @return $this
     */
    public function forNestedWhere()
    {
        return $this->newQuery();
    }

    /**
     * Add another query builder as a nested where to the query builder.
     *
     * @param  DynamoDbQueryBuilder $query
     * @param  string  $boolean
     * @return $this
     */
    public function addNestedWhereQuery($query, $boolean = 'and')
    {
        if (count($query->wheres)) {
            $type = 'Nested';
            $column = null;
            $value = $query->wheres;
            $this->wheres[] = compact('column', 'type', 'value', 'boolean');
        }

        return $this;
    }

    /**
     * Add an "or where" clause to the query.
     *
     * @param  string  $column
     * @param  string  $operator
     * @param  mixed   $value
     * @return $this
     */
    public function orWhere($column, $operator = null, $value = null)
    {
        return $this->where($column, $operator, $value, 'or');
    }

    /**
     * Add a "where in" clause to the query.
     *
     * @param  string  $column
     * @param  mixed   $values
     * @param  string  $boolean
     * @param  bool    $not
     * @return $this
     * @throws NotSupportedException
     */
    public function whereIn($column, $values, $boolean = 'and', $not = false)
    {
        if ($not) {
            throw new NotSupportedException('"not in" is not a valid DynamoDB comparison operator');
        }

        // If the value is a query builder instance, not supported
        if ($values instanceof static) {
            throw new NotSupportedException('Value is a query builder instance');
        }

        // If the value of the where in clause is actually a Closure, not supported
        if ($values instanceof Closure) {
            throw new NotSupportedException('Value is a Closure');
        }

        // Next, if the value is Arrayable we need to cast it to its raw array form
        if ($values instanceof Arrayable) {
            $values = $values->toArray();
        }

        return $this->where($column, ComparisonOperator::IN, $values, $boolean);
    }

    /**
     * Add an "or where in" clause to the query.
     *
     * @param  string  $column
     * @param  mixed   $values
     * @return $this
     */
    public function orWhereIn($column, $values)
    {
        return $this->whereIn($column, $values, 'or');
    }

    /**
     * Add a "where null" clause to the query.
     *
     * @param  string  $column
     * @param  string  $boolean
     * @param  bool    $not
     * @return $this
     */
    public function whereNull($column, $boolean = 'and', $not = false)
    {
        $type = $not ? ComparisonOperator::NOT_NULL : ComparisonOperator::NULL;

        $this->wheres[] = compact('column', 'type', 'boolean');

        return $this;
    }

    /**
     * Add an "or where null" clause to the query.
     *
     * @param  string  $column
     * @return $this
     */
    public function orWhereNull($column)
    {
        return $this->whereNull($column, 'or');
    }

    /**
     * Add an "or where not null" clause to the query.
     *
     * @param  string  $column
     * @return $this
     */
    public function orWhereNotNull($column)
    {
        return $this->whereNotNull($column, 'or');
    }

    /**
     * Add a "where not null" clause to the query.
     *
     * @param  string  $column
     * @param  string  $boolean
     * @return $this
     */
    public function whereNotNull($column, $boolean = 'and')
    {
        return $this->whereNull($column, $boolean, true);
    }

    /**
     * Get a new instance of the query builder.
     *
     * @return DynamoDbQueryBuilder
     */
    public function newQuery()
    {
        return new static($this->getModel());
    }

    /**
     * Implements the Query Chunk method
     *
     * @param int $chunkSize
     * @param callable $callback
     */
    public function chunk($chunkSize, callable $callback)
    {
        while (true) {
            $results = $this->getAll([], $chunkSize, false);

            if (!$results->isEmpty()) {
                if (call_user_func($callback, $results) === false) {
                    return false;
                }
            }

            if (empty($this->lastEvaluatedKey)) {
                break;
            }
        }

        return true;
    }

    /**
     * @param $id
     * @param array $columns
     * @return DynamoDbModel|\Illuminate\Database\Eloquent\Collection|null
     */
    public function find($id, array $columns = [])
    {
        if ($this->isMultipleIds($id)) {
            return $this->findMany($id, $columns);
        }

        $this->resetExpressions();

        $this->model->setId($id);

        $query = DynamoDb::table($this->model->getTable())
            ->setKey(DynamoDb::marshalItem($this->model->getKeys()))
            ->setConsistentRead(true);

        if (!empty($columns)) {
            $query
                ->setProjectionExpression($this->projectionExpression->parse($columns))
                ->setExpressionAttributeNames($this->expressionAttributeNames->all());
        }

        $item = $query->prepare($this->client)->getItem();

        $item = Arr::get($item->toArray(), 'Item');

        if (empty($item)) {
            return null;
        }

        $item = DynamoDb::unmarshalItem($item);

        $model = $this->model->newInstance([], true);

        $model->setRawAttributes($item, true);

        return $model;
    }

    /**
     * @param $ids
     * @param array $columns
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public function findMany($ids, array $columns = [])
    {
        $collection = $this->model->newCollection();

        if (empty($ids)) {
            return $collection;
        }

        $this->resetExpressions();

        $table = $this->model->getTable();

        $keys = collect($ids)->map(function ($id) {
            if (! is_array($id)) {
                $id = [$this->model->getKeyName() => $id];
            }

            return DynamoDb::marshalItem($id);
        });

        $subQuery = DynamoDb::newQuery()
            ->setKeys($keys->toArray())
            ->setProjectionExpression($this->projectionExpression->parse($columns))
            ->setExpressionAttributeNames($this->expressionAttributeNames->all())
            ->prepare($this->client)
            ->query;

        $results = DynamoDb::newQuery()
            ->setRequestItems([$table => $subQuery])
            ->prepare($this->client)
            ->batchGetItem();

        foreach ($results['Responses'][$table] as $item) {
            $item = DynamoDb::unmarshalItem($item);
            $model = $this->model->newInstance([], true);
            $model->setRawAttributes($item, true);
            $collection->add($model);
        }

        return $collection;
    }

    public function findOrFail($id, $columns = [])
    {
        $result = $this->find($id, $columns);

        if ($this->isMultipleIds($id)) {
            if (count($result) == count(array_unique($id))) {
                return $result;
            }
        } elseif (! is_null($result)) {
            return $result;
        }

        throw (new ModelNotFoundException)->setModel(
            get_class($this->model),
            $id
        );
    }

    public function first($columns = [])
    {
        $items = $this->getAll($columns, 1);

        return $items->first();
    }

    public function firstOrFail($columns = [])
    {
        if (! is_null($model = $this->first($columns))) {
            return $model;
        }

        throw (new ModelNotFoundException)->setModel(get_class($this->model));
    }

    /**
     * Remove attributes from an existing item
     *
     * @param array ...$attributes
     * @return bool
     * @throws InvalidQuery
     */
    public function removeAttribute(...$attributes)
    {
        $keySet = !empty(array_filter($this->model->getKeys()));

        if (!$keySet) {
            $analyzer = $this->getConditionAnalyzer();

            if (!$analyzer->isExactSearch()) {
                throw new InvalidQuery('Need to provide the key in your query');
            }

            $id = $analyzer->identifierConditionValues();
            $this->model->setId($id);
        }

        $key = DynamoDb::marshalItem($this->model->getKeys());

        $this->resetExpressions();

        /** @var \Aws\Result $result */
        $result = DynamoDb::table($this->model->getTable())
            ->setKey($key)
            ->setUpdateExpression($this->updateExpression->remove($attributes))
            ->setExpressionAttributeNames($this->expressionAttributeNames->all())
            ->setReturnValues('ALL_NEW')
            ->prepare($this->client)
            ->updateItem();

        $success = Arr::get($result, '@metadata.statusCode') === 200;

        if ($success) {
            $this->model->setRawAttributes(DynamoDb::unmarshalItem($result->get('Attributes')));
            $this->model->syncOriginal();
        }

        return $success;
    }

    public function delete()
    {
        $result = DynamoDb::table($this->model->getTable())
            ->setKey(DynamoDb::marshalItem($this->model->getKeys()))
            ->prepare($this->client)
            ->deleteItem();

        return Arr::get($result->toArray(), '@metadata.statusCode') === 200;
    }

    public function deleteAsync()
    {
        $promise = DynamoDb::table($this->model->getTable())
            ->setKey(DynamoDb::marshalItem($this->model->getKeys()))
            ->prepare($this->client)
            ->deleteItemAsync();

        return $promise;
    }

    public function save()
    {
        $result = DynamoDb::table($this->model->getTable())
            ->setItem(DynamoDb::marshalItem($this->model->getAttributes()))
            ->prepare($this->client)
            ->putItem();

        return Arr::get($result, '@metadata.statusCode') === 200;
    }

    public function saveAsync()
    {
        $promise = DynamoDb::table($this->model->getTable())
            ->setItem(DynamoDb::marshalItem($this->model->getAttributes()))
            ->prepare($this->client)
            ->putItemAsync();

        return $promise;
    }

    public function get($columns = [])
    {
        return $this->all($columns);
    }

    public function all($columns = [])
    {
        $limit = isset($this->limit) ? $this->limit : static::MAX_LIMIT;
        return $this->getAll($columns, $limit, !isset($this->limit));
    }

    public function count()
    {
        $limit = isset($this->limit) ? $this->limit : static::MAX_LIMIT;
        $raw = $this->toDynamoDbQuery(['count(*)'], $limit);

        if ($raw->op === 'Scan') {
            $res = $this->client->scan($raw->query);
        } else {
            $res = $this->client->query($raw->query);
        }

        return $res['Count'];
    }

    public function decorate(Closure $closure)
    {
        $this->decorator = $closure;
        return $this;
    }

    protected function getAll(
        $columns = [],
        $limit = DynamoDbQueryBuilder::MAX_LIMIT,
        $useIterator = DynamoDbQueryBuilder::DEFAULT_TO_ITERATOR
    ) {
        $analyzer = $this->getConditionAnalyzer();

        if ($analyzer->isExactSearch()) {
            $item = $this->find($analyzer->identifierConditionValues(), $columns);

            return $this->getModel()->newCollection([$item]);
        }

        $raw = $this->toDynamoDbQuery($columns, $limit);

        if ($useIterator) {
            $iterator = $this->client->getIterator($raw->op, $raw->query);

            if (isset($raw->query['Limit'])) {
                $iterator = new \LimitIterator($iterator, 0, $raw->query['Limit']);
            }
        } else {
            if ($raw->op === 'Scan') {
                $res = $this->client->scan($raw->query);
            } else {
                $res = $this->client->query($raw->query);
            }

            $this->lastEvaluatedKey = Arr::get($res, 'LastEvaluatedKey');
            $iterator = $res['Items'];
        }

        $results = [];

        foreach ($iterator as $item) {
            $item = DynamoDb::unmarshalItem($item);
            $model = $this->model->newInstance([], true);
            $model->setRawAttributes($item, true);
            $results[] = $model;
        }

        return $this->getModel()->newCollection($results, $analyzer->index());
    }

    /**
     * Return the raw DynamoDb query
     *
     * @param array $columns
     * @param int $limit
     * @return RawDynamoDbQuery
     */
    public function toDynamoDbQuery(
        $columns = [],
        $limit = DynamoDbQueryBuilder::MAX_LIMIT
    ) {
        $this->applyScopes();

        $this->resetExpressions();

        $op = 'Scan';
        $queryBuilder = DynamoDb::table($this->model->getTable());

        if (! empty($this->wheres)) {
            $analyzer = $this->getConditionAnalyzer();

            if ($keyConditions = $analyzer->keyConditions()) {
                $op = 'Query';
                $queryBuilder->setKeyConditionExpression($this->keyConditionExpression->parse($keyConditions));
            }

            if ($filterConditions = $analyzer->filterConditions()) {
                $queryBuilder->setFilterExpression($this->filterExpression->parse($filterConditions));
            }

            if ($index = $analyzer->index()) {
                $queryBuilder->setIndexName($index->name);
            }
        }

        if ($this->index) {
            // If user specifies the index manually, respect that
            $queryBuilder->setIndexName($this->index);
        }

        if ($limit !== static::MAX_LIMIT) {
            $queryBuilder->setLimit($limit);
        }

        if (!empty($columns)) {
            // Either we try to get the count or specific columns
            if ($columns == ['count(*)']) {
                $queryBuilder->setSelect('COUNT');
            } else {
                $queryBuilder->setProjectionExpression($this->projectionExpression->parse($columns));
            }
        }

        if (!empty($this->lastEvaluatedKey)) {
            $queryBuilder->setExclusiveStartKey($this->lastEvaluatedKey);
        }

        $queryBuilder
            ->setExpressionAttributeNames($this->expressionAttributeNames->all())
            ->setExpressionAttributeValues($this->expressionAttributeValues->all());

        $raw = new RawDynamoDbQuery($op, $queryBuilder->prepare($this->client)->query);

        if ($this->decorator) {
            call_user_func($this->decorator, $raw);
        }

        return $raw;
    }

    /**
     * @return Analyzer
     */
    protected function getConditionAnalyzer()
    {
        return with(new Analyzer)
            ->on($this->model)
            ->withIndex($this->index)
            ->analyze($this->wheres);
    }

    protected function isMultipleIds($id)
    {
        $keys = collect($this->model->getKeyNames());

        // could be ['id' => 'foo'], ['id1' => 'foo', 'id2' => 'bar']
        $single = $keys->first(function ($name) use ($id) {
            return !isset($id[$name]);
        }) === null;

        if ($single) {
            return false;
        }

        // could be ['foo', 'bar'], [['id1' => 'foo', 'id2' => 'bar'], ...]
        return $this->model->hasCompositeKey() ? is_array(H::array_first($id)) : is_array($id);
    }

    /**
     * @return DynamoDbModel
     */
    public function getModel()
    {
        return $this->model;
    }

    /**
     * @return \Aws\DynamoDb\DynamoDbClient
     */
    public function getClient()
    {
        return $this->client;
    }

    /**
     * Register a new global scope.
     *
     * @param  string  $identifier
     * @param  \Illuminate\Database\Eloquent\Scope|\Closure  $scope
     * @return $this
     */
    public function withGlobalScope($identifier, $scope)
    {
        $this->scopes[$identifier] = $scope;

        if (method_exists($scope, 'extend')) {
            $scope->extend($this);
        }

        return $this;
    }

    /**
     * Remove a registered global scope.
     *
     * @param  \Illuminate\Database\Eloquent\Scope|string  $scope
     * @return $this
     */
    public function withoutGlobalScope($scope)
    {
        if (! is_string($scope)) {
            $scope = get_class($scope);
        }

        unset($this->scopes[$scope]);

        $this->removedScopes[] = $scope;

        return $this;
    }

    /**
     * Remove all or passed registered global scopes.
     *
     * @param  array|null  $scopes
     * @return $this
     */
    public function withoutGlobalScopes(array $scopes = null)
    {
        if (is_array($scopes)) {
            foreach ($scopes as $scope) {
                $this->withoutGlobalScope($scope);
            }
        } else {
            $this->scopes = [];
        }

        return $this;
    }

    /**
     * Get an array of global scopes that were removed from the query.
     *
     * @return array
     */
    public function removedScopes()
    {
        return $this->removedScopes;
    }

    /**
     * Apply the scopes to the Eloquent builder instance and return it.
     *
     * @return DynamoDbQueryBuilder
     */
    public function applyScopes()
    {
        if (! $this->scopes) {
            return $this;
        }

        $builder = $this;

        foreach ($builder->scopes as $identifier => $scope) {
            if (! isset($builder->scopes[$identifier])) {
                continue;
            }

            $builder->callScope(function (DynamoDbQueryBuilder $builder) use ($scope) {
                // If the scope is a Closure we will just go ahead and call the scope with the
                // builder instance. The "callScope" method will properly group the clauses
                // that are added to this query so "where" clauses maintain proper logic.
                if ($scope instanceof Closure) {
                    $scope($builder);
                }

                // If the scope is a scope object, we will call the apply method on this scope
                // passing in the builder and the model instance. After we run all of these
                // scopes we will return back the builder instance to the outside caller.
                if ($scope instanceof Scope) {
                    throw new NotSupportedException('Scope object is not yet supported');
                }
            });

            $builder->withoutGlobalScope($identifier);
        }

        return $builder;
    }

    /**
     * Apply the given scope on the current builder instance.
     *
     * @param  callable  $scope
     * @param  array  $parameters
     * @return mixed
     */
    protected function callScope(callable $scope, $parameters = [])
    {
        array_unshift($parameters, $this);

        // $query = $this->getQuery();

        // // We will keep track of how many wheres are on the query before running the
        // // scope so that we can properly group the added scope constraints in the
        // // query as their own isolated nested where statement and avoid issues.
        // $originalWhereCount = is_null($query->wheres)
        //             ? 0 : count($query->wheres);

        $result = $scope(...array_values($parameters)) ?: $this;

        // if (count((array) $query->wheres) > $originalWhereCount) {
        //     $this->addNewWheresWithinGroup($query, $originalWhereCount);
        // }

        return $result;
    }

    /**
     * Dynamically handle calls into the query instance.
     *
     * @param  string  $method
     * @param  array  $parameters
     * @return mixed
     */
    public function __call($method, $parameters)
    {
        if (method_exists($this->model, $scope = 'scope'.ucfirst($method))) {
            return $this->callScope([$this->model, $scope], $parameters);
        }

        return $this;
    }
}
