<?php
/**
 * @link https://craftcms.com/
 * @copyright Copyright (c) Pixel & Tonic, Inc.
 * @license https://craftcms.github.io/license/
 */

namespace craft\services;

use Craft;
use craft\base\UtilityInterface;
use craft\db\Query;
use craft\db\Table;
use craft\elements\Asset;
use craft\elements\Category;
use craft\elements\Entry;
use craft\elements\User;
use craft\enums\CmsEdition;
use craft\enums\PropagationMethod;
use craft\errors\WrongEditionException;
use craft\events\ConfigEvent;
use craft\events\RegisterUserPermissionsEvent;
use craft\events\UserGroupPermissionsEvent;
use craft\events\UserPermissionsEvent;
use craft\helpers\Db;
use craft\helpers\ProjectConfig as ProjectConfigHelper;
use craft\helpers\StringHelper;
use craft\models\Section;
use craft\models\UserGroup;
use craft\records\UserPermission as UserPermissionRecord;
use craft\utilities\ProjectConfig as ProjectConfigUtility;
use yii\base\Component;
use yii\db\Exception;

/**
 * User Permissions service.
 *
 * An instance of the service is available via [[\craft\base\ApplicationTrait::getUserPermissions()|`Craft::$app->getUserPermissions()`]].
 *
 * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
 * @since 3.0.0
 */
class UserPermissions extends Component
{
    /**
     * @event RegisterUserPermissionsEvent The event that is triggered when registering user permissions.
     */
    public const EVENT_REGISTER_PERMISSIONS = 'registerPermissions';

    /**
     * @event UserPermissionsEvent The event triggered before saving user permissions.
     * @since 4.3.0
     */
    public const EVENT_AFTER_SAVE_USER_PERMISSIONS = 'afterSaveUserPermissions';

    /**
     * @event UserGroupPermissionsEvent The event triggered before saving group permissions.
     * @since 4.3.0
     */
    public const EVENT_AFTER_SAVE_GROUP_PERMISSIONS = 'afterSaveGroupPermissions';

    /**
     * @var array|null
     * @see getAllPermissions()
     */
    private array|null $_allPermissions = null;

    /**
     * @var string[]|null
     * @see validatePermission()
     */
    private array|null $_allPermissionNames = null;

    /**
     * @var string[][]
     */
    private array $_permissionsByGroupId = [];

    /**
     * @var string[][]
     */
    private array $_permissionsByUserId = [];

    /**
     * Returns all of the known permissions, divided into groups.
     *
     * Each group will have two keys:
     *
     * - `heading` – The human-facing heading text for the group
     * - `permissions` – An array of permissions for the group
     *
     * Each item of the `permissions` array will have a key set to the permission name
     * (e.g. `accessSiteWhenSystemIsOff`), and a value set to an array with the following keys:
     *
     * - `label` – The human-facing permission label
     * - `info` _(optional)_ – Informational text about the permission
     * - `warning` _(optional)_ – Warning text about the permission
     * - `nested` _(optional)_ – An array of nested permissions, which can only be assigned if the parent
     *   permission is assigned.
     *
     * @return array
     */
    public function getAllPermissions(): array
    {
        if (!isset($this->_allPermissions)) {
            $this->_allPermissions = [];

            $this->_generalPermissions($this->_allPermissions);
            $this->_userPermissions($this->_allPermissions);
            $this->_sitePermissions($this->_allPermissions);
            $this->_entryPermissions($this->_allPermissions);
            $this->_globalSetPermissions($this->_allPermissions);
            $this->_categoryPermissions($this->_allPermissions);
            $this->_volumePermissions($this->_allPermissions);
            $this->_utilityPermissions($this->_allPermissions);

            // Fire a 'registerPermissions' event
            if ($this->hasEventHandlers(self::EVENT_REGISTER_PERMISSIONS)) {
                $event = new RegisterUserPermissionsEvent([
                    'permissions' => $this->_allPermissions,
                ]);
                $this->trigger(self::EVENT_REGISTER_PERMISSIONS, $event);
                $this->_allPermissions = $event->permissions;
            }
        }

        return $this->_allPermissions;
    }

    /**
     * Returns the permissions that the current user is allowed to assign to another user.
     *
     * See [[getAllPermissions()]] for an explanation of what will be returned.
     *
     * @param User|null $user The recipient of the permissions. If set, their current permissions will be included as well.
     * @return array
     */
    public function getAssignablePermissions(?User $user = null): array
    {
        // If either user is an admin, all permissions are fair game
        if (Craft::$app->getUser()->getIsAdmin() || ($user !== null && $user->admin)) {
            return $this->getAllPermissions();
        }

        $allowedPermissions = [];

        foreach ($this->getAllPermissions() as $group) {
            $filteredPermissions = $this->_filterUnassignablePermissions($group['permissions'], $user);

            if (!empty($filteredPermissions)) {
                $allowedPermissions[] = [
                    'heading' => $group['heading'],
                    'permissions' => $filteredPermissions,
                ];
            }
        }

        return $allowedPermissions;
    }

    /**
     * Returns all of a given user group's permissions.
     *
     * @param int $groupId
     * @return string[]
     */
    public function getPermissionsByGroupId(int $groupId): array
    {
        if (!isset($this->_permissionsByGroupId[$groupId])) {
            $this->_permissionsByGroupId[$groupId] = $this->_createUserPermissionsQuery()
                ->innerJoin(['p_g' => Table::USERPERMISSIONS_USERGROUPS], '[[p_g.permissionId]] = [[p.id]]')
                ->where(['p_g.groupId' => $groupId])
                ->column();
        }

        return $this->_permissionsByGroupId[$groupId];
    }

    /**
     * Returns all of the group permissions a given user has.
     *
     * @param int $userId
     * @return string[]
     */
    public function getGroupPermissionsByUserId(int $userId): array
    {
        if (Craft::$app->edition === CmsEdition::Team) {
            $group = Craft::$app->getUserGroups()->getTeamGroup();
            return $this->getPermissionsByGroupId($group->id);
        }

        return $this->_createUserPermissionsQuery()
            ->innerJoin(['p_g' => Table::USERPERMISSIONS_USERGROUPS], '[[p_g.permissionId]] = [[p.id]]')
            ->innerJoin(['g_u' => Table::USERGROUPS_USERS], '[[g_u.groupId]] = [[p_g.groupId]]')
            ->where(['g_u.userId' => $userId])
            ->column();
    }

    /**
     * Returns whether a given user group has a given permission.
     *
     * @param int $groupId
     * @param string $checkPermission
     * @return bool
     */
    public function doesGroupHavePermission(int $groupId, string $checkPermission): bool
    {
        $allPermissions = $this->getPermissionsByGroupId($groupId);
        $checkPermission = strtolower($checkPermission);

        return in_array($checkPermission, $allPermissions, true);
    }

    /**
     * Saves new permissions for a user group.
     *
     * @param int $groupId
     * @param array $permissions
     * @return bool
     * @throws WrongEditionException if this is called from Craft Solo edition
     */
    public function saveGroupPermissions(int $groupId, array $permissions): bool
    {
        Craft::$app->requireEdition(CmsEdition::Team);

        // Lowercase the permissions
        $permissions = array_map('strtolower', $permissions);

        // Filter out any orphaned permissions
        $permissions = $this->_filterOrphanedPermissions($permissions);

        // Sort ascending
        sort($permissions);

        /** @var UserGroup $group */
        $group = Craft::$app->getUserGroups()->getGroupById($groupId);
        $path = ProjectConfig::PATH_USER_GROUPS . '.' . $group->uid . '.permissions';
        Craft::$app->getProjectConfig()->set($path, $permissions, "Update permissions for user group “{$group->handle}”");

        // Fire an 'afterSaveGroupPermissions' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_GROUP_PERMISSIONS)) {
            $this->trigger(self::EVENT_AFTER_SAVE_GROUP_PERMISSIONS, new UserGroupPermissionsEvent([
                'groupId' => $groupId,
                'permissions' => $permissions,
            ]));
        }

        return true;
    }

    /**
     * Returns all of a given user’s permissions.
     *
     * @param int $userId
     * @return array
     */
    public function getPermissionsByUserId(int $userId): array
    {
        if (!isset($this->_permissionsByUserId[$userId])) {
            $groupPermissions = $this->getGroupPermissionsByUserId($userId);

            if (Craft::$app->edition->value >= CmsEdition::Pro->value) {
                /** @var string[] $userPermissions */
                $userPermissions = $this->_createUserPermissionsQuery()
                    ->innerJoin(['p_u' => Table::USERPERMISSIONS_USERS], '[[p_u.permissionId]] = [[p.id]]')
                    ->where(['p_u.userId' => $userId])
                    ->column();
            } else {
                $userPermissions = [];
            }

            $this->_permissionsByUserId[$userId] = array_unique(array_merge($groupPermissions, $userPermissions));
        }

        return $this->_permissionsByUserId[$userId];
    }

    /**
     * @param string $permission
     * @return bool
     * @since 5.8.13.2
     */
    public function validatePermission(string $permission): bool
    {
        if (!isset($this->_allPermissionNames)) {
            $this->_allPermissionNames = [];
            foreach ($this->getAllPermissions() as $group) {
                $this->collectPermissionNames($group['permissions']);
            }
        }

        return isset($this->_allPermissionNames[strtolower($permission)]);
    }

    private function collectPermissionNames(array &$permissions): void
    {
        foreach ($permissions as $name => $permission) {
            $this->_allPermissionNames[strtolower($name)] = true;

            if (isset($permission['nested'])) {
                $this->collectPermissionNames($permission['nested']);
            }
        }
    }

    /**
     * Returns whether a given user has a given permission.
     *
     * @param int $userId
     * @param string $checkPermission
     * @return bool
     */
    public function doesUserHavePermission(int $userId, string $checkPermission): bool
    {
        if (strcasecmp($checkPermission, 'accessCp') === 0 && Craft::$app->edition === CmsEdition::Team) {
            return true;
        }

        $allPermissions = $this->getPermissionsByUserId($userId);
        $checkPermission = strtolower($checkPermission);

        return in_array($checkPermission, $allPermissions, true);
    }

    /**
     * Saves new permissions for a user.
     *
     * @param int $userId
     * @param array $permissions
     * @return bool
     * @throws WrongEditionException if this is called from Craft Solo edition
     * @throws Exception
     */
    public function saveUserPermissions(int $userId, array $permissions): bool
    {
        Craft::$app->requireEdition(CmsEdition::Pro);

        // Delete any existing user permissions
        Db::delete(Table::USERPERMISSIONS_USERS, [
            'userId' => $userId,
        ]);

        // Lowercase the permissions
        $permissions = array_map('strtolower', $permissions);

        // Filter out any orphaned permissions
        $groupPermissions = $this->getGroupPermissionsByUserId($userId);
        $permissions = $this->_filterOrphanedPermissions($permissions, $groupPermissions);

        if (!empty($permissions)) {
            $userPermissionVals = [];

            foreach ($permissions as $permissionName) {
                $permissionRecord = $this->_getPermissionRecordByName($permissionName);
                $userPermissionVals[] = [$permissionRecord->id, $userId];
            }

            // Add the new user permissions
            Db::batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $userPermissionVals);
        }

        // Cache the new permissions
        $this->_permissionsByUserId[$userId] = array_unique(array_merge($groupPermissions, $permissions));

        // Fire an 'afterSaveUserPermissions' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_USER_PERMISSIONS)) {
            $this->trigger(self::EVENT_AFTER_SAVE_USER_PERMISSIONS, new UserPermissionsEvent([
                'userId' => $userId,
                'permissions' => $permissions,
            ]));
        }

        return true;
    }

    /**
     * Handle any changed group permissions.
     *
     * @param ConfigEvent $event
     */
    public function handleChangedGroupPermissions(ConfigEvent $event): void
    {
        // Ensure all user groups are ready to roll
        ProjectConfigHelper::ensureAllUserGroupsProcessed();
        $uid = $event->tokenMatches[0];
        $permissions = $event->newValue;
        $userGroup = Craft::$app->getUserGroups()->getGroupByUid($uid);

        // No group - no permissions to change.
        if (!$userGroup) {
            return;
        }

        // Delete any existing group permissions
        Db::delete(Table::USERPERMISSIONS_USERGROUPS, [
            'groupId' => $userGroup->id,
        ]);

        $groupPermissionVals = [];

        if ($permissions) {
            foreach ($permissions as $permissionName) {
                $permissionRecord = $this->_getPermissionRecordByName($permissionName);
                $groupPermissionVals[] = [$permissionRecord->id, $userGroup->id];
            }

            // Add the new group permissions
            Db::batchInsert(Table::USERPERMISSIONS_USERGROUPS, ['permissionId', 'groupId'], $groupPermissionVals);
        }

        // Update caches
        $this->_permissionsByGroupId[$userGroup->id] = $permissions;
    }

    private function _generalPermissions(array &$permissions): void
    {
        $generalPermissions = [
            'accessSiteWhenSystemIsOff' => [
                'label' => Craft::t('app', 'Access the site when the system is off'),
            ],
        ];

        $cpPermissions = [
            'accessCpWhenSystemIsOff' => [
                'label' => Craft::t('app', 'Access the control panel when the system is offline'),
            ],
            'performUpdates' => [
                'label' => Craft::t('app', 'Perform Craft CMS and plugin updates'),
            ],
        ];

        foreach (Craft::$app->getPlugins()->getAllPlugins() as $plugin) {
            if ($plugin->hasCpSection) {
                $cpPermissions["accessPlugin-$plugin->id"] = [
                    'label' => Craft::t('app', 'Access {plugin}', ['plugin' => $plugin->name]),
                ];
            }
        }

        switch (Craft::$app->edition) {
            case CmsEdition::Team:
                $generalPermissions = array_merge($generalPermissions, $cpPermissions);
                break;
            case CmsEdition::Pro:
            case CmsEdition::Enterprise:
                $generalPermissions['accessCp'] = [
                    'label' => Craft::t('app', 'Access the control panel'),
                    'warning' => Craft::t('app', 'Includes read-only access to user data and most content, via element selector modals and other means.'),
                    'nested' => $cpPermissions,
                ];
                break;
        }

        $permissions[] = [
            'heading' => Craft::t('app', 'General'),
            'permissions' => $generalPermissions,
        ];
    }

    private function _userPermissions(array &$permissions): void
    {
        $assignGroupPermissions = [];

        if (Craft::$app->edition->value >= CmsEdition::Pro->value) {
            foreach (Craft::$app->getUserGroups()->getAllGroups() as $group) {
                $assignGroupPermissions["assignUserGroup:$group->uid"] = [
                    'label' => Craft::t('app', 'Assign users to “{group}”', [
                        'group' => Craft::t('site', $group->name),
                    ]),
                ];
            }
        }

        $permissions[] = [
            'heading' => Craft::t('app', 'Users'),
            'permissions' => [
                'viewUsers' => [
                    'label' => Craft::t('app', 'View {type}', [
                        'type' => User::pluralLowerDisplayName(),
                    ]),
                    'nested' => [
                        'editUsers' => [
                            'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Edit {type}', [
                                'type' => User::pluralLowerDisplayName(),
                            ])),
                            'nested' => array_merge(
                                array_filter([
                                    'registerUsers' => [
                                        'label' => Craft::t('app', 'Register users'),
                                    ],
                                    'moderateUsers' => [
                                        'label' => Craft::t('app', 'Moderate users'),
                                        'info' => Craft::t('app', 'Includes suspending, unsuspending, and unlocking user accounts.'),
                                    ],
                                    'administrateUsers' => [
                                        'label' => Craft::t('app', 'Administrate users'),
                                        'info' => Craft::t('app', 'Includes activating/deactivating user accounts, resetting passwords, and changing email addresses.'),
                                        'warning' => Craft::$app->edition->value >= CmsEdition::Pro->value
                                            ? Craft::t('app', 'Accounts with this permission could use it to escalate their own permissions.')
                                            : null,
                                    ],
                                    'impersonateUsers' => [
                                        'label' => Craft::t('app', 'Impersonate users'),
                                    ],
                                    'assignUserPermissions' => Craft::$app->edition->value >= CmsEdition::Pro->value
                                        ? [
                                            'label' => Craft::t('app', 'Assign user permissions'),
                                        ]
                                        : null,
                                ]),
                                $assignGroupPermissions
                            ),
                        ],
                        'deleteUsers' => [
                            'label' => Craft::t('app', 'Delete users'),
                        ],
                    ],
                ],
            ],
        ];
    }

    private function _sitePermissions(array &$permissions): void
    {
        if (!Craft::$app->getIsMultiSite()) {
            return;
        }

        $sitePermissions = [];

        foreach (Craft::$app->getSites()->getAllSites(true) as $site) {
            $sitePermissions["editSite:$site->uid"] = [
                'label' => Craft::t('app', 'Edit “{title}”', [
                    'title' => Craft::t('site', $site->getName()),
                ]),
            ];
        }

        $permissions[] = [
            'heading' => Craft::t('app', 'Sites'),
            'permissions' => $sitePermissions,
        ];
    }

    private function _entryPermissions(array &$permissions): void
    {
        $sections = Craft::$app->getEntries()->getAllSections();

        if (!$sections) {
            return;
        }

        $type = Entry::lowerDisplayName();
        $pluralType = Entry::pluralLowerDisplayName();

        foreach ($sections as $section) {
            if ($section->type == Section::TYPE_SINGLE) {
                $sectionPermissions = [
                    "viewEntries:$section->uid" => [
                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View {type}', ['type' => $type])),
                        'nested' => [
                            "saveEntries:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save {type}', ['type' => $type])),
                            ],
                            "viewPeerEntryDrafts:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View other users’ {type}', [
                                    'type' => Craft::t('app', 'drafts'),
                                ])),
                                'nested' => [
                                    "savePeerEntryDrafts:$section->uid" => [
                                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save other users’ {type}', [
                                            'type' => Craft::t('app', 'drafts'),
                                        ])),
                                    ],
                                    "deletePeerEntryDrafts:$section->uid" => [
                                        'label' => Craft::t('app', 'Delete other users’ {type}', [
                                            'type' => Craft::t('app', 'drafts'),
                                        ]),
                                    ],
                                ],
                            ],
                        ],
                    ],
                ];
            } else {
                $hasCustomPropagation = (
                    $section->propagationMethod === PropagationMethod::Custom &&
                    Craft::$app->getIsMultiSite()
                );

                $sectionPermissions = [
                    "viewEntries:$section->uid" => [
                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View {type}', ['type' => $pluralType])),
                        'info' => Craft::t('app', 'Allows viewing existing {type} and creating drafts for them.', [
                            'type' => $pluralType,
                        ]),
                        'nested' => array_filter([
                            "createEntries:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Create {type}', ['type' => $pluralType])),
                                'info' => Craft::t('app', 'Allows creating drafts of new {type}.', ['type' => $pluralType]),
                            ],
                            "saveEntries:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save {type}', ['type' => $pluralType])),
                                'info' => Craft::t('app', 'Allows fully saving canonical {type} (directly or by applying drafts).', [
                                    'type' => $pluralType,
                                ]),
                            ],
                            "deleteEntriesForSite:$section->uid" => $hasCustomPropagation ? [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type} for site', ['type' => $pluralType])),
                                'info' => Craft::t('app', 'Allows deleting {type} for individual sites, provided the user has access to them.', [
                                    'type' => $pluralType,
                                ]),
                            ] : null,
                            "deleteEntries:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type}', ['type' => $pluralType])),
                                'info' => Craft::t('app', 'Allows deleting {type} for all sites.', [
                                    'type' => $pluralType,
                                ]),
                            ],
                            "viewPeerEntries:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View other users’ {type}', ['type' => $pluralType])),
                                'nested' => array_filter([
                                    "savePeerEntries:$section->uid" => [
                                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save other users’ {type}', ['type' => $pluralType])),
                                        'nested' => [
                                            "changeAuthorForPeerEntries:$section->uid" => [
                                                'label' => Craft::t('app', 'Change the author of other users’ entries'),
                                            ],
                                        ],
                                    ],
                                    "deletePeerEntriesForSite:$section->uid" => $hasCustomPropagation ? [
                                        'label' => Craft::t('app', 'Delete other users’ {type} for site', ['type' => $pluralType]),
                                        'info' => Craft::t('app', 'Allows deleting other users’ {type} for individual sites, provided the user has access to them.', [
                                            'type' => $pluralType,
                                        ]),
                                    ] : null,
                                    "deletePeerEntries:$section->uid" => [
                                        'label' => Craft::t('app', 'Delete other users’ {type}', ['type' => $pluralType]),
                                        'info' => Craft::t('app', 'Allows deleting other users’ {type} for all sites.', [
                                            'type' => $pluralType,
                                        ]),
                                    ],
                                ]),
                            ],
                            "viewPeerEntryDrafts:$section->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View other users’ {type}', [
                                    'type' => Craft::t('app', 'drafts'),
                                ])),
                                'nested' => [
                                    "savePeerEntryDrafts:$section->uid" => [
                                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save other users’ {type}', [
                                            'type' => Craft::t('app', 'drafts'),
                                        ])),
                                    ],
                                    "deletePeerEntryDrafts:$section->uid" => [
                                        'label' => Craft::t('app', 'Delete other users’ {type}', [
                                            'type' => Craft::t('app', 'drafts'),
                                        ]),
                                    ],
                                ],
                            ],
                        ]),
                    ],
                ];
            }

            $permissions[] = [
                'heading' => Craft::t('app', 'Section - {section}', [
                    'section' => Craft::t('site', $section->name),
                ]),
                'permissions' => $sectionPermissions,
            ];
        }
    }

    private function _globalSetPermissions(array &$permissions): void
    {
        $globalSets = Craft::$app->getGlobals()->getAllSets();

        if (!$globalSets) {
            return;
        }

        $globalSetPermissions = [];

        foreach ($globalSets as $globalSet) {
            $globalSetPermissions["editGlobalSet:$globalSet->uid"] = [
                'label' => Craft::t('app', 'Edit “{title}”', [
                    'title' => Craft::t('site', $globalSet->name),
                ]),
            ];
        }

        $permissions[] = [
            'heading' => Craft::t('app', 'Global Sets'),
            'permissions' => $globalSetPermissions,
        ];
    }

    private function _categoryPermissions(array &$permissions): void
    {
        $categoryGroups = Craft::$app->getCategories()->getAllGroups();

        if (!$categoryGroups) {
            return;
        }

        $type = Category::pluralLowerDisplayName();

        foreach ($categoryGroups as $group) {
            $permissions[] = [
                'heading' => Craft::t('app', 'Category Group - {name}', [
                    'name' => Craft::t('site', $group->name),
                ]),
                'permissions' => [
                    "viewCategories:$group->uid" => [
                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View {type}', ['type' => $type])),
                        'nested' => [
                            "saveCategories:$group->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save {type}', ['type' => $type])),
                            ],
                            "deleteCategories:$group->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type}', ['type' => $type])),
                            ],
                            "viewPeerCategoryDrafts:$group->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View other users’ {type}', [
                                    'type' => Craft::t('app', 'drafts'),
                                ])),
                                'nested' => [
                                    "savePeerCategoryDrafts:$group->uid" => [
                                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save other users’ {type}', [
                                            'type' => Craft::t('app', 'drafts'),
                                        ])),
                                    ],
                                    "deletePeerCategoryDrafts:$group->uid" => [
                                        'label' => Craft::t('app', 'Delete other users’ {type}', [
                                            'type' => Craft::t('app', 'drafts'),
                                        ]),
                                    ],
                                ],
                            ],
                        ],
                    ],
                ],
            ];
        }
    }

    private function _volumePermissions(array &$permissions): void
    {
        $volumes = Craft::$app->getVolumes()->getAllVolumes();

        if (!$volumes) {
            return;
        }

        $type = Asset::pluralLowerDisplayName();

        foreach ($volumes as $volume) {
            $permissions[] = [
                'heading' => Craft::t('app', 'Volume - {volume}', [
                    'volume' => Craft::t('site', $volume->name),
                ]),
                'permissions' => [
                    "viewAssets:$volume->uid" => [
                        'label' => StringHelper::upperCaseFirst(Craft::t('app', 'View {type}', ['type' => $type])),
                        'nested' => [
                            "saveAssets:$volume->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save {type}', ['type' => $type])),
                            ],
                            "deleteAssets:$volume->uid" => [
                                'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type}', ['type' => $type])),
                            ],
                            "replaceFiles:$volume->uid" => [
                                'label' => Craft::t('app', 'Replace files'),
                            ],
                            "editImages:$volume->uid" => [
                                'label' => Craft::t('app', 'Edit images'),
                            ],
                            "viewPeerAssets:$volume->uid" => [
                                'label' => Craft::t('app', 'View assets uploaded by other users'),
                                'nested' => [
                                    "savePeerAssets:$volume->uid" => [
                                        'label' => Craft::t('app', 'Save assets uploaded by other users'),
                                    ],
                                    "replacePeerFiles:$volume->uid" => [
                                        'label' => Craft::t('app', 'Replace files uploaded by other users'),
                                        'warning' => Craft::t('app', 'When someone replaces a file, the record of who uploaded the file will be updated as well.'),
                                    ],
                                    "deletePeerAssets:$volume->uid" => [
                                        'label' => Craft::t('app', 'Remove files uploaded by other users'),
                                    ],
                                    "editPeerImages:$volume->uid" => [
                                        'label' => Craft::t('app', 'Edit images uploaded by other users'),
                                    ],
                                ],
                            ],
                            "createFolders:$volume->uid" => [
                                'label' => Craft::t('app', 'Create subfolders'),
                            ],
                        ],
                    ],
                ],
            ];
        }
    }

    private function _utilityPermissions(array &$permissions): void
    {
        $utilityPermissions = [];

        foreach (Craft::$app->getUtilities()->getAllUtilityTypes() as $class) {
            /** @var UtilityInterface $class */
            // Admins only
            if (ProjectConfigUtility::id() === $class::id()) {
                continue;
            }

            $utilityPermissions[sprintf('utility:%s', $class::id())] = [
                'label' => $class::displayName(),
            ];
        }

        $permissions[] = [
            'heading' => Craft::t('app', 'Utilities'),
            'permissions' => $utilityPermissions,
        ];
    }

    /**
     * Filters out any permissions that aren't assignable by the current user.
     *
     * @param array $permissions The original permissions
     * @param User|null $user The recipient of the permissions. If set, their current permissions will be included as well.
     * @return array The filtered permissions
     */
    private function _filterUnassignablePermissions(array $permissions, ?User $user = null): array
    {
        $currentUser = Craft::$app->getUser()->getIdentity();
        if (!$currentUser && !$user) {
            return [];
        }

        $assignablePermissions = [];

        foreach ($permissions as $name => $data) {
            if (($currentUser !== null && $currentUser->can($name)) || ($user !== null && $user->can($name))) {
                if (isset($data['nested'])) {
                    $data['nested'] = $this->_filterUnassignablePermissions($data['nested'], $user);
                }

                $assignablePermissions[$name] = $data;
            }
        }

        return $assignablePermissions;
    }

    /**
     * Filters out any orphaned permissions.
     *
     * @param array $postedPermissions The posted permissions.
     * @param array $groupPermissions Permissions the user is already assigned
     * to via their group, if we’re saving a user’s permissions.
     * @return array The permissions we'll actually let them save.
     */
    private function _filterOrphanedPermissions(array $postedPermissions, array $groupPermissions = []): array
    {
        $filteredPermissions = [];

        if (!empty($postedPermissions)) {
            foreach ($this->getAllPermissions() as $group) {
                $this->_findSelectedPermissions($group['permissions'], $postedPermissions, $groupPermissions, $filteredPermissions);
            }
        }

        return $filteredPermissions;
    }

    /**
     * Iterates through a group of permissions, returning the ones that were selected.
     *
     * @param array $permissionsGroup
     * @param array $postedPermissions
     * @param array $groupPermissions
     * @param array $filteredPermissions
     * @return bool Whether any permissions were added to $filteredPermissions
     */
    private function _findSelectedPermissions(array $permissionsGroup, array $postedPermissions, array $groupPermissions, array &$filteredPermissions): bool
    {
        $hasAssignedPermissions = false;

        foreach ($permissionsGroup as $name => $data) {
            $name = strtolower($name);
            // Should the user have this permission (either directly or via their group)?
            if (($inPostedPermissions = in_array($name, $postedPermissions, true)) || in_array($name, $groupPermissions, true)) {
                // First assign any nested permissions
                if (!empty($data['nested'])) {
                    $hasAssignedNestedPermissions = $this->_findSelectedPermissions($data['nested'], $postedPermissions, $groupPermissions, $filteredPermissions);
                } else {
                    $hasAssignedNestedPermissions = false;
                }

                // Were they assigned this permission (or any of its nested permissions) directly?
                if ($inPostedPermissions || $hasAssignedNestedPermissions) {
                    // Assign the permission directly to the user
                    $filteredPermissions[] = $name;
                    $hasAssignedPermissions = true;
                }
            }
        }

        return $hasAssignedPermissions;
    }

    /**
     * Returns a permission record based on its name. If a record doesn't exist, it will be created.
     *
     * @param string $permissionName
     * @return UserPermissionRecord
     */
    private function _getPermissionRecordByName(string $permissionName): UserPermissionRecord
    {
        // Permission names are always stored in lowercase
        $permissionName = strtolower($permissionName);

        $permissionRecord = UserPermissionRecord::findOne(['name' => $permissionName]);

        if (!$permissionRecord) {
            $permissionRecord = new UserPermissionRecord();
            $permissionRecord->name = $permissionName;
            $permissionRecord->save();
        }

        return $permissionRecord;
    }

    /**
     * @return Query
     */
    private function _createUserPermissionsQuery(): Query
    {
        return (new Query())
            ->select(['p.name'])
            ->from(['p' => Table::USERPERMISSIONS]);
    }

    /**
     * Resets the internal state
     *
     * @since 5.8.13
     */
    public function reset(): void
    {
        $this->_allPermissions = null;
        $this->_allPermissionNames = null;
        $this->_permissionsByGroupId = [];
        $this->_permissionsByUserId = [];
    }
}
