<?php

declare(strict_types=1);

namespace HiEvents\Repository\Eloquent;

use BadMethodCallException;
use Carbon\Carbon;
use HiEvents\DomainObjects\Interfaces\DomainObjectInterface;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Models\BaseModel;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\RepositoryInterface;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use TypeError;

/**
 * @template T of DomainObjectInterface
 * @implements RepositoryInterface<T>
 */
abstract class BaseRepository implements RepositoryInterface
{
    protected Model|BaseModel|Builder $model;

    protected Application $app;

    protected DatabaseManager $db;

    protected int $maxPerPage = self::MAX_PAGINATE_LIMIT;

    /** @var Relationship[] */
    protected array $eagerLoads = [];

    public function __construct(Application $application, DatabaseManager $db)
    {
        $this->app = $application;
        $this->model = $this->initModel();
        $this->db = $db;
    }

    /**
     * Returns a FQCL of the model
     *
     * @return string
     */
    abstract protected function getModel(): string;

    /**
     * @param class-string<IsSortable> $domainObjectClass
     */
    protected function validateSortColumn(?string $sortBy, string $domainObjectClass): string
    {
        $allowedColumns = array_keys($domainObjectClass::getAllowedSorts()->toArray());
        $default = $domainObjectClass::getDefaultSort();

        if ($sortBy === null || !in_array($sortBy, $allowedColumns, true)) {
            return $default;
        }

        return $sortBy;
    }

    protected function validateSortDirection(?string $sortDirection, string $domainObjectClass): string
    {
        return in_array(strtolower($sortDirection ?? ''), ['asc', 'desc'], true)
            ? $sortDirection
            : $domainObjectClass::getDefaultSortDirection();
    }

    public function setMaxPerPage(int $maxPerPage): static
    {
        $this->maxPerPage = $maxPerPage;

        return $this;
    }

    public function all(array $columns = self::DEFAULT_COLUMNS): Collection
    {
        $models = $this->model->all($columns);
        $this->resetModel();

        return $this->handleResults($models);
    }

    public function paginate(
        ?int  $limit = null,
        array $columns = self::DEFAULT_COLUMNS
    ): LengthAwarePaginator
    {
        $results = $this->model->paginate($this->getPaginationPerPage($limit), $columns);
        $this->resetModel();

        return $this->handleResults($results);
    }

    public function paginateWhere(
        array $where,
        ?int  $limit = null,
        array $columns = self::DEFAULT_COLUMNS,
        ?int  $page = null,
    ): LengthAwarePaginator
    {
        $this->applyConditions($where);
        $results = $this->model->paginate(
            perPage: $this->getPaginationPerPage($limit),
            columns: $columns,
            page: $page,
        );
        $this->resetModel();

        return $this->handleResults($results);
    }

    public function simplePaginateWhere(
        array $where,
        ?int  $limit = null,
        array $columns = self::DEFAULT_COLUMNS,
    ): Paginator
    {
        $this->applyConditions($where);
        $results = $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns);
        $this->resetModel();

        return $this->handleResults($results);
    }

    public function paginateEloquentRelation(
        Relation $relation,
        ?int     $limit = null,
        array    $columns = self::DEFAULT_COLUMNS
    ): LengthAwarePaginator
    {
        return $this->handleResults($relation->paginate($this->getPaginationPerPage($limit), $columns));
    }

    /**
     * @throws ModelNotFoundException
     */
    public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface
    {
        $model = $this->model->findOrFail($id, $columns);
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function findFirstByField(
        string  $field,
        ?string $value = null,
        array   $columns = ['*']
    ): ?DomainObjectInterface
    {
        $model = $this->model->where($field, '=', $value)->first($columns);
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface
    {
        $model = $this->model->findOrFail($id, $columns);
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function findWhere(
        array $where,
        array $columns = self::DEFAULT_COLUMNS,
        array $orderAndDirections = [],
    ): Collection
    {
        $this->applyConditions($where);

        if ($orderAndDirections) {
            foreach ($orderAndDirections as $orderAndDirection) {
                $this->model = $this->model->orderBy(
                    $orderAndDirection->getOrder(),
                    $orderAndDirection->getDirection()
                );
            }
        }

        $model = $this->model->get($columns);

        $this->resetModel();

        return $this->handleResults($model);
    }

    public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface
    {
        $this->applyConditions($where);
        $model = $this->model->first($columns);
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection
    {
        if ($additionalWhere) {
            $this->applyConditions($additionalWhere);
        }

        $model = $this->model->whereIn($field, $values)->get($columns);
        $this->resetModel();

        return $this->handleResults($model);
    }

    public function create(array $attributes): DomainObjectInterface
    {
        $model = $this->model->newInstance(collect($attributes)->toArray());
        $model->save();
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function insert(array $inserts): bool
    {
        // When doing a bulk insert Eloquent doesn't autofill the updated/created dates,
        // so we need to do it manually
        foreach ($inserts as $index => $insert) {
            if (!isset($insert['created_at'], $insert['updated_at'])) {
                $now = Carbon::now();
                $inserts[$index]['created_at'] = $now;
                $inserts[$index]['updated_at'] = $now;
            }
        }
        $insert = $this->model->insert($inserts);
        $this->resetModel();

        return $insert;
    }

    public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface
    {
        return $this->updateFromArray($id, $domainObject->toArray());
    }

    public function updateFromArray(int $id, array $attributes): DomainObjectInterface
    {
        $model = $this->model->findOrFail($id);
        $model->fill($attributes);
        $model->save();
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function updateWhere(array $attributes, array $where): int
    {
        $this->applyConditions($where);
        $count = $this->model->update($attributes);
        $this->resetModel();

        return $count;
    }

    public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface
    {
        $model = $this->model->where($where)->findOrFail($id);
        $model->update($attributes);
        $this->resetModel();

        return $this->handleSingleResult($model);
    }

    public function deleteById(int $id): bool
    {
        return $this->model->findOrFail($id)->delete();
    }

    public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null): int
    {
        if ($where) {
            $this->applyConditions($where);
        }

        $count = $this->model->incrementEach($columns, $additionalUpdates);
        $this->resetModel();

        return $count;
    }

    public function decrementEach(array $where, array $columns, array $extra = []): int
    {
        $this->applyConditions($where);
        $count = $this->model->decrementEach($columns, $extra);
        $this->resetModel();

        return $count;
    }

    public function increment(int|float $id, string $column, int|float $amount = 1): int
    {
        return $this->model->findOrFail($id)->increment($column, $amount);
    }

    public function incrementWhere(array $where, string $column, int|float $amount = 1): int
    {
        $this->applyConditions($where);
        $count = $this->model->increment($column, $amount);
        $this->resetModel();

        return $count;
    }

    public function decrement(int|float $id, string $column, int|float $amount = 1): int
    {
        return $this->model->findOrFail($id)?->decrement($column, $amount);
    }

    public function deleteWhere(array $conditions): int
    {
        $this->applyConditions($conditions);
        $deleted = $this->model->delete();
        $this->resetModel();

        return $deleted;
    }

    public function countWhere(array $conditions): int
    {
        $this->applyConditions($conditions);
        $count = $this->model->count();
        $this->resetModel();

        return $count;
    }

    public function loadRelation(string|Relationship $relationship): static
    {
        if (is_string($relationship)) {
            $relationship = new Relationship($relationship);
        }

        $this->eagerLoads[] = $relationship;
        $this->model = $this->model->with($relationship->buildLaravelEagerLoadArray());

        return $this;
    }

    public function includeDeleted(): static
    {
        $this->model = $this->model->withTrashed();

        return $this;
    }

    protected function applyConditions(array $where): void
    {
        foreach ($where as $field => $value) {
            if (is_callable($value) && !is_string($value)) {
                $this->model = $this->model->where($value);
            } elseif (is_array($value)) {
                [$field, $condition, $val] = $value;
                $condition = strtolower($condition);

                switch ($condition) {
                    case 'in':
                        if (is_array($val)) {
                            $this->model = $this->model->whereIn($field, $val);
                        }
                        break;

                    case 'not in':
                        if (is_array($val)) {
                            $this->model = $this->model->whereNotIn($field, $val);
                        }
                        break;

                    case 'null':
                        $this->model = $this->model->whereNull($field);
                        break;

                    case 'not null':
                        $this->model = $this->model->whereNotNull($field);
                        break;

                    default:
                        $this->model = $this->model->where($field, $condition, $val);
                        break;
                }
            } else {
                // Simple equality condition
                $this->model = $this->model->where($field, '=', $value);
            }
        }
    }

    protected function initModel(?string $model = null): Model
    {
        return $this->app->make($model ?: $this->getModel());
    }

    protected function handleResults($results, ?string $domainObjectOverride = null)
    {
        $domainObjects = [];
        foreach ($results as $result) {
            if ($result && $domainObject = $this->handleSingleResult($result, $domainObjectOverride)) {
                $domainObjects[] = $domainObject;
            }
        }

        if ($results instanceof LengthAwarePaginator) {
            return $results->setCollection(collect($domainObjects));
        }

        if ($results instanceof Paginator) {
            return $results->setCollection(collect($domainObjects));
        }

        return collect($domainObjects);
    }

    protected function handleSingleResult(
        ?BaseModel $model,
        ?string    $domainObjectOverride = null
    ): ?DomainObjectInterface
    {
        if (!$model) {
            return null;
        }

        return $this->hydrateDomainObjectFromModel($model, $domainObjectOverride);
    }

    protected function applyFilterFields(
        QueryParamsDTO $params,
        array $allowedFilterFields = [],
        ?string $prefix = null,
    ): void
    {
        if ($params->filter_fields && $params->filter_fields->isNotEmpty()) {
            $params->filter_fields->each(function ($filterField) use ($prefix, $allowedFilterFields) {
                if (!in_array($filterField->field, $allowedFilterFields, true)) {
                    return;
                }

                $isNull = $filterField->value === 'null';

                $operatorMapping = [
                    'eq' => $isNull ? 'IS' : '=',
                    'ne' => $isNull ? 'IS NOT' : '!=',
                    'lt' => '<',
                    'lte' => '<=',
                    'gt' => '>',
                    'gte' => '>=',
                    'like' => 'LIKE',
                    'in' => 'IN',
                ];

                $operator = $operatorMapping[$filterField->operator] ?? throw new BadMethodCallException(
                    sprintf('Operator %s is not supported', $filterField->operator)
                );

                $field = $prefix ? $prefix . '.' . $filterField->field : $filterField->field;

                // Special handling for IN operator
                if ($operator === 'IN') {
                    // Ensure value is array or convert comma-separated string to array
                    $value = is_array($filterField->value)
                        ? $filterField->value
                        : explode(',', $filterField->value);

                    $this->model = $this->model->whereIn(
                        column: $field,
                        values: $value
                    );
                } else {
                    $this->model = $this->model->where(
                        column: $field,
                        operator: $operator,
                        value: $isNull ? null : $filterField->value,
                    );
                }
            });
        }
    }

    protected function resetModel(): void
    {
        $model = $this->getModel();
        $this->model = new $model();
    }

    private function getPaginationPerPage(?int $perPage): int
    {
        if (is_null($perPage)) {
            $perPage = self::DEFAULT_PAGINATE_LIMIT;
        }

        return (int)min($perPage, $this->maxPerPage);
    }

    /**
     * @param Model $model
     * @param string|null $domainObjectOverride A FQCN of a DO
     * @param array|null $relationships
     * @return DomainObjectInterface
     *
     * @todo use hydrate method from AbstractDomainObject
     */
    private function hydrateDomainObjectFromModel(
        Model   $model,
        ?string $domainObjectOverride = null,
        ?array  $relationships = null,
    ): DomainObjectInterface
    {
        /** @var DomainObjectInterface $object */
        $object = $domainObjectOverride ?: $this->getDomainObject();
        $object = new $object();

        foreach ($model->attributesToArray() as $attribute => $value) {
            $method = 'set' . ucfirst(Str::camel($attribute));
            if (is_callable(array($object, $method))) {
                try {
                    $object->$method($value);
                } catch (TypeError $e) {
                    throw new TypeError(
                        sprintf(
                            'Type error when calling %s::%s with value %s: %s',
                            get_class($object),
                            $method,
                            var_export($value, true),
                            $e->getMessage()
                        ),
                        (int)$e->getCode(),
                        $e
                    );
                }

            }
        }

        $this->handleEagerLoads($model, $object, $relationships);

        return $object;
    }

    /**
     * This method will handle nested eager loading of relationships
     *
     * @param Model $model
     * @param DomainObjectInterface $object
     * @param Relationship[]|null $relationships
     *
     * @return void
     */
    private function handleEagerLoads(Model $model, DomainObjectInterface $object, ?array $relationships): void
    {
        $eagerLoads = $relationships ?: $this->eagerLoads;

        foreach ($eagerLoads as $eagerLoad) {
            if (!$model->relationLoaded($eagerLoad->getName())) {
                continue;
            }
            $relatedModels = $model->getRelation($eagerLoad->getName());
            $setterMethod = 'set' . Str::studly($eagerLoad->getName());

            if (!is_callable([$object, $setterMethod])) {
                throw new BadMethodCallException(
                    sprintf(
                        'Method %s is not callable on %s. Does it exist?',
                        $setterMethod,
                        get_class($object),
                    )
                );
            }

            if ($relatedModels instanceof Collection) {
                $relatedDomainObjects = $relatedModels->map(function ($relatedModel) use ($eagerLoad) {
                    return $this->hydrateDomainObjectFromModel(
                        $relatedModel,
                        $eagerLoad->getDomainObject(),
                        $eagerLoad->getNested(),
                    );
                });
                $object->$setterMethod($relatedDomainObjects);
            } else if ($relatedModels instanceof BaseModel) {
                $relatedDomainObject = $this->hydrateDomainObjectFromModel(
                    $relatedModels,
                    $eagerLoad->getDomainObject(),
                    $eagerLoad->getNested(),
                );

                $object->$setterMethod($relatedDomainObject);
            }
        }
    }
}
