<?php

namespace Kirschbaum\PowerJoins;

use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str;
use InvalidArgumentException;
use ReflectionClass;

class PowerJoinClause extends JoinClause
{
    /**
     * @var Model
     */
    public $model;

    /**
     * Table name backup in case an alias is being used.
     *
     * @var string
     */
    public $tableName;

    /**
     * Alias name.
     */
    public ?string $alias = null;

    /**
     * Joined table alias name (mostly for belongs to many aliases).
     */
    public ?string $joinedTableAlias = null;

    /**
     * Create a new join clause instance.
     */
    public function __construct(Builder $parentQuery, $type, string $table, ?Model $model = null)
    {
        parent::__construct($parentQuery, $type, $table);

        $this->model = $model;
        $this->tableName = $table;
    }

    /**
     * Add an alias to the table being joined.
     */
    public function as(string $alias, ?string $joinedTableAlias = null): self
    {
        $this->alias = $alias;
        $this->joinedTableAlias = $joinedTableAlias;
        $this->table = sprintf('%s as %s', $this->table, $alias);
        $this->useTableAliasInConditions();

        if ($this->model) {
            StaticCache::setTableAliasForModel($this->model, $alias);
        }

        return $this;
    }

    public function on($first, $operator = null, $second = null, $boolean = 'and'): self
    {
        parent::on($first, $operator, $second, $boolean);
        $this->useTableAliasInConditions();

        return $this;
    }

    public function getModel()
    {
        return $this->model;
    }

    /**
     * Apply the global scopes to the joined query.
     */
    public function withGlobalScopes(): self
    {
        if (!$this->model) {
            return $this;
        }

        foreach ($this->model->getGlobalScopes() as $scope) {
            if ($scope instanceof Closure) {
                $scope->call($this, $this);
                continue;
            }

            if ($scope instanceof SoftDeletingScope) {
                continue;
            }

            (new $scope())->apply($this, $this->model);
        }

        return $this;
    }

    /**
     * Apply the table alias in the existing join conditions.
     */
    protected function useTableAliasInConditions(): self
    {
        if (!$this->alias || !$this->model) {
            return $this;
        }

        $this->wheres = collect($this->wheres)->filter(function ($where) {
            $whereType = $where['type'] ?? '';

            if (in_array($whereType, ['Column', 'Basic'], true)) {
                return true;
            }

            if ($whereType === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) {
                return true;
            }

            return false;
        })->map(function ($where) {
            if ($where['type'] === 'Null') {
                return $where;
            }

            $key = $this->model->getKeyName();
            $table = $this->tableName;
            $replaceMethod = sprintf('useAliasInWhere%sType', ucfirst($where['type']));

            return $this->{$replaceMethod}($where);
        })->toArray();

        return $this;
    }

    protected function useAliasInWhereColumnType(array $where): array
    {
        $key = $this->model->getKeyName();
        $table = $this->tableName;

        // if it was already replaced, skip
        if (Str::startsWith($where['first'].'.', $this->alias.'.') || Str::startsWith($where['second'].'.', $this->alias.'.')) {
            return $where;
        }

        if (Str::contains($where['first'], $table) && Str::contains($where['second'], $table)) {
            // if joining the same table, only replace the correct table.key pair
            $where['first'] = str_replace($table.'.'.$key, $this->alias.'.'.$key, $where['first']);
            $where['second'] = str_replace($table.'.'.$key, $this->alias.'.'.$key, $where['second']);
        } else {
            $where['first'] = str_replace($table.'.', $this->alias.'.', $where['first']);
            $where['second'] = str_replace($table.'.', $this->alias.'.', $where['second']);
        }

        return $where;
    }

    protected function useAliasInWhereBasicType(array $where): array
    {
        $table = $this->tableName;

        if (Str::startsWith($where['column'].'.', $this->alias.'.')) {
            return $where;
        }

        if (Str::contains($where['column'], $table)) {
            // if joining the same table, only replace the correct table.key pair
            $where['column'] = str_replace($table.'.', $this->alias.'.', $where['column']);
        } else {
            $where['column'] = str_replace($table.'.', $this->alias.'.', $where['column']);
        }

        return $where;
    }

    public function whereNull($columns, $boolean = 'and', $not = false)
    {
        if ($this->alias && Str::contains($columns, $this->tableName)) {
            $columns = str_replace("{$this->tableName}.", "{$this->alias}.", $columns);
        }

        return parent::whereNull($columns, $boolean, $not);
    }

    public function newQuery(): self
    {
        return new static($this->newParentQuery(), $this->type, $this->table, $this->model); // <-- The model param is needed
    }

    public function where($column, $operator = null, $value = null, $boolean = 'and'): self
    {
        if ($this->alias && is_string($column) && Str::contains($column, $this->tableName)) {
            $column = str_replace("{$this->tableName}.", "{$this->alias}.", $column);
        } elseif ($this->alias && !is_callable($column)) {
            $column = $this->alias.'.'.$column;
        }

        if (is_callable($column)) {
            $query = new self($this, $this->type, $this->table, $this->model);
            $column($query);

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

        return parent::where($column, $operator, $value, $boolean);
    }

    /**
     * Remove the soft delete condition in case the model implements soft deletes.
     */
    public function withTrashed(): self
    {
        if (!$this->getModel() || !in_array(SoftDeletes::class, class_uses_recursive($this->getModel()), true)) {
            return $this;
        }

        $this->wheres = array_filter($this->wheres, function ($where) {
            if ($where['type'] === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) {
                return false;
            }

            return true;
        });

        return $this;
    }

    /**
     * Remove the soft delete condition in case the model implements soft deletes.
     */
    public function onlyTrashed(): self
    {
        if (!$this->getModel()
            || !in_array(SoftDeletes::class, class_uses_recursive($this->getModel()), true)
        ) {
            return $this;
        }

        $hasCondition = null;

        $this->wheres = array_map(function ($where) use (&$hasCondition) {
            if ($where['type'] === 'Null' && Str::contains($where['column'], $this->getModel()->getDeletedAtColumn())) {
                $where['type'] = 'NotNull';
                $hasCondition = true;
            }

            return $where;
        }, $this->wheres);

        if (!$hasCondition) {
            $this->whereNotNull($this->getModel()->getQualifiedDeletedAtColumn());
        }

        return $this;
    }

    public function left(): self
    {
        return $this->joinType('left');
    }

    public function right(): self
    {
        return $this->joinType('right');
    }

    public function inner(): self
    {
        return $this->joinType('inner');
    }

    public function joinType(string $joinType): self
    {
        $this->type = $joinType;

        return $this;
    }

    public function __call($name, $arguments)
    {
        if (!$this->getModel()) {
            return;
        }

        if (method_exists($this->getModel(), 'scope'.ucfirst($name))) {
            $scope = 'scope'.ucfirst($name);

            return $this->getModel()->{$scope}($this, ...$arguments);
        }

        if ($this->hasLaravelScopeAttribute($name) && version_compare(app()->version(), '12.0.0', '>=')) {
            return $this->getModel()->callNamedScope($name, array_merge([$this], $arguments));
        }

        if (static::hasMacro($name)) {
            return $this->macroCall($name, $arguments);
        }

        $eloquentBuilder = $this->getModel()->newEloquentBuilder($this);
        if (method_exists($eloquentBuilder, $name)) {
            $eloquentBuilder->setModel($this->getModel());

            return $eloquentBuilder->{$name}(...$arguments);
        }

        throw new InvalidArgumentException(sprintf('Method %s does not exist in PowerJoinClause class', $name));
    }

    /**
     * Check if a method has the Laravel Scope attribute.
     */
    protected function hasLaravelScopeAttribute(string $methodName): bool
    {
        if (!method_exists($this->getModel(), $methodName)) {
            return false;
        }

        $reflection = new ReflectionClass($this->getModel());

        if (!$reflection->hasMethod($methodName)) {
            return false;
        }

        $method = $reflection->getMethod($methodName);
        $attributes = $method->getAttributes();

        foreach ($attributes as $attribute) {
            if ($attribute->getName() === 'Illuminate\Database\Eloquent\Attributes\Scope') {
                return true;
            }
        }

        return false;
    }
}
