<?php

namespace Spatie\Permission\Traits;

use BackedEnum;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Contracts\Permission;
use Spatie\Permission\Contracts\Role;
use Spatie\Permission\Events\RoleAttachedEvent;
use Spatie\Permission\Events\RoleDetachedEvent;
use Spatie\Permission\PermissionRegistrar;
use Spatie\Permission\Support\Config;
use TypeError;

use function Illuminate\Support\enum_value;

trait HasRoles
{
    use HasPermissions;

    private ?string $roleClass = null;

    public static function bootHasRoles(): void
    {
        static::deleting(function ($model) {
            if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) {
                return;
            }

            $teams = app(PermissionRegistrar::class)->teams;
            app(PermissionRegistrar::class)->teams = false;
            $model->roles()->detach();
            if ($model instanceof Permission) {
                $model->users()->detach();
            }
            app(PermissionRegistrar::class)->teams = $teams;
        });
    }

    public function getRoleClass(): string
    {
        if (! $this->roleClass) {
            $this->roleClass = app(PermissionRegistrar::class)->getRoleClass();
        }

        return $this->roleClass;
    }

    /**
     * A model may have multiple roles.
     */
    public function roles(): BelongsToMany
    {
        $relation = $this->morphToMany(
            Config::roleModel(),
            'model',
            Config::modelHasRolesTable(),
            Config::morphKey(),
            app(PermissionRegistrar::class)->pivotRole
        );

        if (! Config::teamsEnabled()) {
            return $relation;
        }

        $teamsKey = Config::teamForeignKey();
        $relation->withPivot($teamsKey);
        $teamField = Config::rolesTable().'.'.$teamsKey;

        return $relation->wherePivot($teamsKey, getPermissionsTeamId())
            ->where(fn ($q) => $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId()));
    }

    /**
     * Scope the model query to certain roles only.
     *
     * @param  string|int|array|Role|Collection|BackedEnum  $roles
     */
    public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $without = false): Builder
    {
        if ($roles instanceof Collection) {
            $roles = $roles->all();
        }

        $roles = array_map(function ($role) use ($guard) {
            if ($role instanceof Role) {
                return $role;
            }

            $role = enum_value($role);

            $method = is_int($role) || PermissionRegistrar::isUid($role) ? 'findById' : 'findByName';

            return $this->getRoleClass()::{$method}($role, $guard ?: $this->getDefaultGuardName());
        }, Arr::wrap($roles));

        $key = (new ($this->getRoleClass())())->getKeyName();

        return $query->{! $without ? 'whereHas' : 'whereDoesntHave'}('roles', fn (Builder $subQuery) => $subQuery
            ->whereIn(Config::rolesTable().".$key", array_column($roles, $key))
        );
    }

    /**
     * Scope the model query to only those without certain roles.
     *
     * @param  string|int|array|Role|Collection|BackedEnum  $roles
     */
    public function scopeWithoutRole(Builder $query, $roles, ?string $guard = null): Builder
    {
        return $this->scopeRole($query, $roles, $guard, true);
    }

    /**
     * A model may be part of multiple teams.
     *
     * When the teams feature is disabled this returns an empty BelongsToMany so
     * tooling that introspects model relations (e.g. ide-helper:models) does not
     * break. Querying it is a no-op and produces no rows.
     */
    public function teams(): BelongsToMany
    {
        if (! Config::teamsEnabled()) {
            return $this->morphToMany(
                Config::permissionModel(),
                'model',
                Config::modelHasRolesTable(),
                Config::morphKey(),
                Config::teamForeignKey()
            )->whereRaw('1 = 0');
        }

        return $this->morphToMany(
            Config::teamModel(),
            'model',
            Config::modelHasRolesTable(),
            Config::morphKey(),
            Config::teamForeignKey()
        )->distinct();
    }

    /**
     * Scope the model query to certain teams only.
     *
     * @param  int|string|array|Model|Collection  $teams
     */
    public function scopeTeam(Builder $query, $teams, bool $without = false): Builder
    {
        $teamModel = Config::teamModel();

        if ($teams instanceof Collection) {
            $teams = $teams->all();
        }

        $teamIds = array_map(
            fn ($team) => $team instanceof $teamModel ? $team->getKey() : $team,
            Arr::wrap($teams),
        );

        $pivotTable = Config::modelHasRolesTable();
        $morphKey = Config::morphKey();
        $teamsKey = Config::teamForeignKey();

        return $query->{! $without ? 'whereExists' : 'whereNotExists'}(
            fn ($subQuery) => $subQuery
                ->from($pivotTable)
                ->whereColumn($morphKey, $query->getModel()->getQualifiedKeyName())
                ->where('model_type', $query->getModel()->getMorphClass())
                ->whereIn($teamsKey, $teamIds)
        );
    }

    /**
     * Scope the model query to those without certain teams.
     *
     * @param  int|string|array|Model|Collection  $teams
     */
    public function scopeWithoutTeam(Builder $query, $teams): Builder
    {
        return $this->scopeTeam($query, $teams, true);
    }

    /**
     * Returns array of role ids
     *
     * @param  string|int|array|Role|Collection|BackedEnum  $roles
     */
    private function collectRoles(...$roles): array
    {
        return collect($roles)
            ->flatten()
            ->reduce(function ($array, $role) {
                if ($role === null || $role === '') {
                    return $array;
                }

                $role = $this->getStoredRole($role);

                if (! in_array($role->getKey(), $array)) {
                    $this->ensureModelSharesGuard($role);
                    $array[] = $role->getKey();
                }

                return $array;
            }, []);
    }

    private function detachRoles(?array $roles = null): int
    {
        $relation = $this->roles();

        if (! Config::teamsEnabled() || $this instanceof Permission || $relation->getPivotClass() === Pivot::class) {
            return $relation->detach($roles);
        }

        // Custom pivot deletes do not include the team key, so keep deletion on the scoped pivot query.
        $query = $relation->newPivotQuery();

        if (! is_null($roles)) {
            if (empty($roles)) {
                return 0;
            }

            $query->whereIn($relation->getQualifiedRelatedPivotKeyName(), $roles);
        }

        $results = $query->delete();

        $relation->touchIfTouching();

        return $results;
    }

    /**
     * Assign the given role to the model.
     *
     * @param  string|int|array|Role|Collection|BackedEnum  ...$roles
     * @return $this
     */
    public function assignRole(...$roles): static
    {
        $roles = $this->collectRoles($roles);

        $model = $this->getModel();
        $teamPivot = app(PermissionRegistrar::class)->teams && ! $this instanceof Permission ?
            [app(PermissionRegistrar::class)->teamsKey => getPermissionsTeamId()] : [];

        if ($model->exists) {
            if (app(PermissionRegistrar::class)->teams) {
                // explicit reload in case team has been changed since last load
                $this->load('roles');
            }

            $currentRoles = $this->roles->map(fn ($role) => $role->getKey())->toArray();

            $this->roles()->attach(array_diff($roles, $currentRoles), $teamPivot);
            $model->unsetRelation('roles');
        } else {
            $class = $model::class;
            $saved = false;

            $class::saved(
                function ($object) use ($roles, $model, $teamPivot, &$saved) {
                    if ($saved || $model->getKey() != $object->getKey()) {
                        return;
                    }
                    $model->roles()->attach($roles, $teamPivot);
                    $model->unsetRelation('roles');
                    $saved = true;
                }
            );
        }

        if ($this instanceof Permission) {
            $this->forgetCachedPermissions();
        }

        $this->forgetWildcardPermissionIndex();

        if (Config::eventsEnabled()) {
            event(new RoleAttachedEvent($this->getModel(), $roles));
        }

        return $this;
    }

    /**
     * Revoke the given role from the model.
     *
     * @param  string|int|array|Role|Collection|BackedEnum  ...$role
     * @return $this
     */
    public function removeRole(...$role): static
    {
        $roles = $this->collectRoles($role);

        $this->detachRoles($roles);

        $this->unsetRelation('roles');

        if ($this instanceof Permission) {
            $this->forgetCachedPermissions();
        }

        $this->forgetWildcardPermissionIndex();

        if (Config::eventsEnabled()) {
            event(new RoleDetachedEvent($this->getModel(), $roles));
        }

        return $this;
    }

    /**
     * Remove all current roles and set the given ones.
     *
     * @param  string|int|array|Role|Collection|BackedEnum  ...$roles
     * @return $this
     */
    public function syncRoles(...$roles): static
    {
        if ($this->getModel()->exists) {
            $this->collectRoles($roles);
            if (Config::eventsEnabled()) {
                $currentRoles = $this->roles()->get();
                if ($currentRoles->isNotEmpty()) {
                    $this->removeRole($currentRoles);
                }
            } else {
                $this->detachRoles();
                $this->setRelation('roles', collect());
            }
        }

        return $this->assignRole($roles);
    }

    /**
     * Determine if the model has (one of) the given role(s).
     *
     * @param  string|int|array|Role|Collection|BackedEnum  $roles
     */
    public function hasRole($roles, ?string $guard = null): bool
    {
        $this->loadMissing('roles');

        if (is_string($roles) && str_contains($roles, '|')) {
            $roles = $this->convertPipeToArray($roles);
        }

        if ($roles instanceof BackedEnum) {
            $roles = $roles->value;

            return $this->roles
                ->when($guard, fn ($q) => $q->where('guard_name', $guard))
                ->pluck('name')
                ->contains(fn ($name) => enum_value($name) == $roles);
        }

        if (is_int($roles) || PermissionRegistrar::isUid($roles)) {
            $key = (new ($this->getRoleClass())())->getKeyName();

            return $guard
                ? $this->roles->where('guard_name', $guard)->contains($key, $roles)
                : $this->roles->contains($key, $roles);
        }

        if (is_string($roles)) {
            return $guard
                ? $this->roles->where('guard_name', $guard)->contains('name', $roles)
                : $this->roles->contains('name', $roles);
        }

        if ($roles instanceof Role) {
            return $this->roles->contains($roles->getKeyName(), $roles->getKey());
        }

        if (is_array($roles)) {
            foreach ($roles as $role) {
                if ($this->hasRole($role, $guard)) {
                    return true;
                }
            }

            return false;
        }

        if ($roles instanceof Collection) {
            return $roles->intersect($guard ? $this->roles->where('guard_name', $guard) : $this->roles)->isNotEmpty();
        }

        throw new TypeError('Unsupported type for $roles parameter to hasRole().');
    }

    /**
     * Determine if the model has any of the given role(s).
     *
     * Alias to hasRole() but without Guard controls
     *
     * @param  string|int|array|Role|Collection|BackedEnum  $roles
     */
    public function hasAnyRole(...$roles): bool
    {
        return $this->hasRole($roles);
    }

    /**
     * Determine if the model has all of the given role(s).
     *
     * @param  string|array|Role|Collection|BackedEnum  $roles
     */
    public function hasAllRoles($roles, ?string $guard = null): bool
    {
        $this->loadMissing('roles');

        $roles = enum_value($roles);

        if (is_string($roles) && str_contains($roles, '|')) {
            $roles = $this->convertPipeToArray($roles);
        }

        if (is_string($roles)) {
            return $this->hasRole($roles, $guard);
        }

        if ($roles instanceof Role) {
            return $this->roles->contains($roles->getKeyName(), $roles->getKey());
        }

        $roles = collect()->make($roles)->map(fn ($role) => $role instanceof Role ? $role->name : enum_value($role));

        $roleNames = $guard
            ? $this->roles->where('guard_name', $guard)->pluck('name')
            : $this->getRoleNames();

        $roleNames = $roleNames->transform(fn ($roleName) => enum_value($roleName));

        return $roles->intersect($roleNames) == $roles;
    }

    /**
     * Determine if the model has exactly all of the given role(s).
     *
     * @param  string|array|Role|Collection|BackedEnum  $roles
     */
    public function hasExactRoles($roles, ?string $guard = null): bool
    {
        $this->loadMissing('roles');

        if (is_string($roles) && str_contains($roles, '|')) {
            $roles = $this->convertPipeToArray($roles);
        }

        if (is_string($roles)) {
            $roles = [$roles];
        }

        if ($roles instanceof Role) {
            $roles = [$roles->name];
        }

        $roles = collect()->make($roles)->map(fn ($role) => $role instanceof Role ? $role->name : $role
        );

        return $this->roles->count() == $roles->count() && $this->hasAllRoles($roles, $guard);
    }

    /**
     * Return all permissions directly coupled to the model.
     */
    public function getDirectPermissions(): Collection
    {
        return $this->permissions;
    }

    public function getRoleNames(): Collection
    {
        $this->loadMissing('roles');

        return $this->roles->pluck('name');
    }

    protected function getStoredRole($role): Role
    {
        $role = enum_value($role);

        if (is_int($role) || PermissionRegistrar::isUid($role)) {
            return $this->getRoleClass()::findById($role, $this->getDefaultGuardName());
        }

        if (is_string($role)) {
            return $this->getRoleClass()::findByName($role, $this->getDefaultGuardName());
        }

        return $role;
    }

    protected function convertPipeToArray(string $pipeString): array
    {
        $pipeString = trim($pipeString);

        if (strlen($pipeString) <= 2) {
            return [str_replace('|', '', $pipeString)];
        }

        $quoteCharacter = substr($pipeString, 0, 1);
        $endCharacter = substr($pipeString, -1, 1);

        if ($quoteCharacter !== $endCharacter) {
            return explode('|', $pipeString);
        }

        if (! in_array($quoteCharacter, ["'", '"'])) {
            return explode('|', $pipeString);
        }

        return explode('|', trim($pipeString, $quoteCharacter));
    }
}
