<?php
/** @noinspection PhpComposerExtensionStubsInspection, SqlResolve */

/** @noinspection PhpUnused Module is loaded dynamically. */

use YahnisElsts\AdminMenuEditor\AdminCustomizer\AmeAdminCustomizer;
use YahnisElsts\AdminMenuEditor\Customizable\Schemas\SchemaFactory;
use YahnisElsts\AdminMenuEditor\ImportExport\ameExportableComponent;
use YahnisElsts\AdminMenuEditor\ImportExport\ameImportResult;
use YahnisElsts\AdminMenuEditor\RoleEditor\EditableRoles\EditableRolesFeature;
use YahnisElsts\AdminMenuEditor\RoleEditor\ImportExport\RoleDataTransfer;
use YahnisElsts\JsonReferenceHelper\JsonReferenceException;
use YahnisElsts\JsonReferenceHelper\JsonReferenceHelper;
use YahnisElsts\WpDependencyWrapper\v1\ScriptDependency;

class ameRoleEditor extends amePersistentProModule {
	const REQUIRED_CAPABILITY = 'edit_users';
	const CORE_COMPONENT_ID = ':wordpress:';

	const UPDATE_PREFERENCES_ACTION = 'ws_ame_rex_update_user_preferences';
	const USER_PREFERENCE_KEY = 'ws_ame_rex_prefs';
	const GET_DEFAULT_ROLES_ACTION = 'ws_ame_rex_get_default_roles';
	const BACKUP_CLEANUP_HOOK = 'ws_ame_rex_cleanup_role_backups';
	const USER_DEFINED_CAP_KEY = 'ws_ame_rex_user_defined_caps';

	const SETTINGS_ERROR_TRANSIENT = 'ws_ame_rex_settings_errors';

	protected $tabSlug = 'roles';
	protected $tabTitle = 'Roles';
	protected $settingsFormAction = 'ame-save-role-settings';

	protected $optionName = 'ws_ame_role_editor';

	/**
	 * @var ameRexCapability[]
	 */
	private $capabilities = [];
	/**
	 * @var array
	 */
	private $uncategorizedCapabilities = [];

	/**
	 * @var ameRexComponentRegistry
	 */
	private $knownComponents;
	private $postTypeRegistrants = [];
	private $taxonomyRegistrants = [];

	/**
	 * @var ameRexCategory[]
	 */
	private $componentRootCategories = [];

	/**
	 * @var ameRexCategory[]
	 */
	private $componentCapPrefixes = [];

	/**
	 * @var string Name of the table that stores role data backups.
	 */
	private $backupTable;
	/**
	 * @var bool Backups are currently disabled pending a full implementation.
	 */
	private $backupsEnabled = false;

	/**
	 * @var array List of errors encountered when saving role settings.
	 */
	private $cachedSettingsErrors = [];

	public function __construct($menuEditor) {
		$this->settingsWrapperEnabled = true;
		parent::__construct($menuEditor);

		$this->withEditableRolesFeature(fn(EditableRolesFeature $f) => $f->installHooks());
		add_filter('map_meta_cap', [$this, 'restrictUserEditing'], 10, 4);

		//Optimization: Only record plugins that register post types and taxonomies when the current page is an AME tab.
		if ( isset($_GET['sub_section']) ) {
			add_action('registered_post_type', [$this, 'recordPostTypeOrigin'], 10, 2);
			add_action('registered_taxonomy', [$this, 'recordTaxonomyOrigin'], 10, 3);
		}

		add_action('wp_ajax_' . self::UPDATE_PREFERENCES_ACTION, [$this, 'ajaxUpdateUserPreferences']);
		add_action('wp_ajax_' . self::GET_DEFAULT_ROLES_ACTION, [$this, 'ajaxGetDefaultRoles']);

		$this->localTabStyles['ame-role-editor-styles'] = 'role-editor.css';
		add_action('admin_menu_editor-enqueue_styles-import', [$this, 'enqueueImportTabStyles']);

		add_action('admin_init', [$this, 'maybeLoadUserUiExtensions']);
		add_action('admin_menu_editor-load_tab-' . $this->tabSlug, [$this, 'addTempVisibleUserFilter']);

		/** @var wpdb */
		global $wpdb;
		$this->backupTable = $wpdb->base_prefix . 'ame_role_backups';
		add_action(self::BACKUP_CLEANUP_HOOK, [$this, 'deleteOldRoleBackups']);
	}

	public function enqueueTabScripts() {
		parent::enqueueTabScripts();

		$roleMergeScript = $this->registerRoleMergeScript();

		$roleEditorScript = $this->registerLocalScript(
			'ame-role-editor',
			'role-editor.js',
			[
				'ame-lodash',
				'ame-knockout',
				'jquery',
				'jquery-qtip',
				'ame-actor-manager',
				'ame-actor-selector',
				'ame-ko-extensions',
				$roleMergeScript->getHandle(),
			]
		);

		wp_enqueue_script('ame-role-editor');

		$this->knownComponents = new ameRexComponentRegistry();
		$this->queryInstalledComponents();

		$defaultCapabilities = $this->getDefaultCapabilities();
		$multisiteCapabilities = $this->getMultisiteOnlyCapabilities();

		foreach ($this->getAllCapabilities([wp_get_current_user()]) as $capability => $unusedValue) {
			$descriptor = new ameRexCapability();
			if ( isset($defaultCapabilities[$capability]) || isset($multisiteCapabilities[$capability]) ) {
				$descriptor->componentId = self::CORE_COMPONENT_ID;
			} else {
				$this->uncategorizedCapabilities[$capability] = true;
			}
			$this->capabilities[$capability] = $descriptor;
		}
		//TODO: do_not_allow should never end up in a plugin category. It's part of core.

		$postTypes = $this->getPostTypeDescriptors();
		$this->analysePostTypes($postTypes);

		$taxonomies = $this->findRegisteredTaxonomies();
		$this->analyseTaxonomies($taxonomies);

		//$categorizationStartTime = microtime(true);

		//Check which menu items use what capabilities and what the corresponding components are.
		$this->analyseAdminMenuCapabilities();

		$this->queryCapabilityDatabase();

		$this->assignCapabilitiesToComponents();

		$probablePostTypeCategories = $this->findProbablePostTypeCategories();
		$clusteredCategories = $this->groupSimilarCapabilities();

		foreach ($this->componentRootCategories as $category) {
			//Find component roots that have both subcategories and capabilities and
			//put all freestanding capabilities in a "General" subcategory.
			if ( !empty($category->capabilities) && !empty($category->subcategories) ) {
				$generalCategory = new ameRexCategory('General', $category->componentId);
				$generalCategory->capabilities = $category->capabilities;
				$category->capabilities = [];
				array_unshift($category->subcategories, $generalCategory);
			}
		}

		$coreCategory = $this->loadCoreCategories();

		//Normally, only a Super Admin on Multisite has certain Multisite administration capabilities.
		//However, there is at least one plugin that uses these capabilities even in a regular WP install,
		//so we'll show them as long as they're assigned to at least one role or user.
		if ( !is_multisite() && isset($coreCategory->subcategories['default/multisite']) ) {
			$multisiteCategory = $coreCategory->subcategories['default/multisite'];
			$multisiteCategory->capabilities = array_intersect_key($multisiteCategory->capabilities, $this->capabilities);
			if ( empty($multisiteCategory->capabilities) ) {
				unset($coreCategory->subcategories['default/multisite']);
			}
		}

		/*echo '<pre>';
		print_r($clusteredCategories);
		print_r($probablePostTypeCategories);
		print_r(array_keys($this->uncategorizedCapabilities));
		print_r($this->capabilities);
		exit;*/

		//$elapsed = microtime(true) - $categorizationStartTime;
		//printf('Categorization time: %.3f ms', $elapsed * 1000);
		//exit;

		$customCategories = array_merge($this->componentRootCategories, $probablePostTypeCategories, $clusteredCategories);
		$customCategoryDescriptors = [];
		foreach ($customCategories as $category) {
			/** @var ameRexCategory $category */
			$customCategoryDescriptors[] = $category->toArray();
		}

		$components = [];
		foreach ($this->knownComponents as $id => $component) {
			$components[$id] = $component->toArray();
		}

		$stableMetaCaps = self::loadCapabilities('stable-meta-caps.txt');
		$metaCapMap = [];
		$currentUserId = get_current_user_id();
		foreach ($stableMetaCaps as $metaCap => $unused) {
			$primitiveCaps = map_meta_cap($metaCap, $currentUserId);
			if ( (count($primitiveCaps) === 1) && !in_array('do_not_allow', $primitiveCaps) ) {
				$targetCap = reset($primitiveCaps);
				if ( $targetCap !== $metaCap ) {
					$metaCapMap[$metaCap] = $targetCap;
				}
			}
		}

		$userPreferences = [];
		$userPreferenceData = get_user_meta(get_current_user_id(), self::USER_PREFERENCE_KEY, true);
		if ( is_string($userPreferenceData) && !empty($userPreferenceData) ) {
			$userPreferences = json_decode($userPreferenceData, true);
			if ( !is_array($userPreferences) ) {
				$userPreferences = [];
			}
		}

		$query = $this->menuEditor->get_query_params();
		$selectedActor = null;
		if ( isset($query['selected_actor']) ) {
			$selectedActor = strval($query['selected_actor']);
		}

		$moduleSettings = $this->loadSettings();

		$scriptData = [
			'coreCategory'              => $coreCategory->toArray(),
			'customCategories'          => $customCategoryDescriptors,
			'postTypes'                 => $postTypes,
			'taxonomies'                => $taxonomies,
			'capabilities'              => $this->capabilities,
			'uncategorizedCapabilities' => array_keys($this->uncategorizedCapabilities),
			'deprecatedCapabilities'    => self::loadCapabilities('deprecated-capabilities.txt'),
			'userDefinedCapabilities'   => $this->getUserDefinedCaps(),
			'knownComponents'           => $components,
			'metaCapMap'                => $metaCapMap,
			'roles'                     => $this->getRoleData(),
			'users'                     => [],
			'defaultRoleName'           => get_option('default_role'),
			'trashedRoles'              => [], //todo: Load trashed roles from somewhere.
			'selectedActor'             => $selectedActor,
			'editableRoles'             => ameUtils::get($moduleSettings, 'editableRoles', new stdClass()),
			'userUiSettings'            => ameUtils::get($moduleSettings, 'userUiSettings', new stdClass()),

			'userPreferences'        => $userPreferences,
			'adminAjaxUrl'           => self_admin_url('admin-ajax.php'),
			'updatePreferencesNonce' => wp_create_nonce(self::UPDATE_PREFERENCES_ACTION),
			'getDefaultRolesNonce'   => wp_create_nonce(self::GET_DEFAULT_ROLES_ACTION),
		];

		$roleEditorScript->addJsVariable('wsRexRoleEditorData', $scriptData);
		$roleEditorScript->enqueue();

		//The editable roles UI depends on RexBaseDialog, so it has to be loaded after the main
		//script. Note that this means the main script needs to delay init until the editable roles
		//script is loaded (waiting for the DOM ready event is good enough in this case).
		$this->withEditableRolesFeature(fn(EditableRolesFeature $f) => $f->enqueueScript($roleEditorScript));
	}

	private function registerRoleMergeScript(): ScriptDependency {
		static $roleMergeScript = null;
		if ( $roleMergeScript !== null ) {
			return $roleMergeScript;
		}

		$roleMergeScript = ScriptDependency::create(
			plugins_url('role-merge.js', __FILE__),
			'ame-role-merge-component',
			__DIR__ . '/role-merge.js',
			[
				'ame-knockout',
				'ame-ko-extensions',
				'ame-actor-manager',
				'wp-i18n',
				'jquery-qtip',
			]
		);
		$roleMergeScript->register();

		return $roleMergeScript;
	}

	public function displaySettingsPage() {
		if ( !$this->userCanAccessModule() ) {
			echo 'Error: You don\'t have sufficient permissions to access these settings.';
			return;
		}

		if ( $this->backupsEnabled && !wp_next_scheduled(self::BACKUP_CLEANUP_HOOK) ) {
			wp_schedule_event(time() + 10 * 60, 'daily', self::BACKUP_CLEANUP_HOOK);
		}
		parent::displaySettingsPage();
	}

	private function getPostTypeDescriptors() {
		$results = [];
		$wpPostTypes = get_post_types([], 'objects');

		//Note: When the "map_meta_cap" option is disabled for a CPT, the values of the "cap"
		//object will be treated as primitive capabilities. For example, "read_post" => "read_post"
		//means that WP will actually check if the user has the literal "read_post" capability.

		//On the other hand, when "map_meta_cap" is enabled, some "cap" entries can be re-mapped
		//to other "cap" entries depending on the current user, post owner, post status, etc.

		//These three meta capabilities will always be mapped to something else if "map_meta_cap"
		//is enabled. We'll skip them unless mapping is off or someone has assigned them to a role.
		//Note: It's possible that it would be fine to skip them even then. Not sure.
		$metaCaps = ['edit_post', 'read_post', 'delete_post'];

		foreach ($wpPostTypes as $name => $postType) {
			$isIncluded = $postType->public || !$postType->_builtin;

			//Skip the "attachment" post type. It only has one unique capability (upload_files), which
			//is included in a default group.
			if ( $name === 'attachment' ) {
				$isIncluded = false;
			}

			//Skip AC changesets.
			if ( class_exists(AmeAdminCustomizer::class) ) {
				if ( $name === AmeAdminCustomizer::CHANGESET_POST_TYPE ) {
					$isIncluded = false;
				}
			}

			if ( !$isIncluded ) {
				continue;
			}

			$label = $name;
			$pluralLabel = $name;
			if ( isset($postType->labels, $postType->labels->name) && !empty($postType->labels->name) ) {
				$label = $postType->labels->name;
				$pluralLabel = $postType->labels->name;
			}

			//We want the plural in lowercase, but if there are multiple consecutive uppercase letters
			//then it's probably an acronym. Stuff like "aBc" is probably a contraction or a proper noun.
			if ( !preg_match('@([A-Z]{2}|[a-z][A-Z])@', $pluralLabel) ) {
				$pluralLabel = strtolower($pluralLabel);
			}

			$capabilities = [];
			foreach ((array)$postType->cap as $capType => $capability) {
				//Skip meta caps unless they already exist.
				if ( in_array($capType, $metaCaps) && ($postType->map_meta_cap || !isset($this->capabilities[$capability])) ) {
					continue;
				}

				//Skip the "read" cap. It's redundant - most CPTs use it, and all roles have it by default.
				if ( ($capType === 'read') && ($capability === 'read') ) {
					continue;
				}

				//Some plugins apparently set capability to "false". Perhaps the intention is to disable it.
				if ( $capability === false ) {
					continue;
				}

				$capabilities[$capType] = $capability;
			}

			$component = isset($this->postTypeRegistrants[$name]) ? $this->postTypeRegistrants[$name] : null;

			$descriptor = [
				'label'       => $label,
				'name'        => $name,
				'pluralLabel' => $pluralLabel,
				'permissions' => $capabilities,
				'isDefault'   => isset($postType->_builtin) && $postType->_builtin,
				'componentId' => $component,
			];

			$results[$name] = $descriptor;
		}

		return $results;
	}

	protected function findRegisteredTaxonomies() {
		$registeredTaxonomies = [];
		$usedLabels = ['Categories' => true, 'Category' => true, 'Tags' => true];

		foreach (get_taxonomies([], 'object') as $taxonomy) {
			$permissions = (array)($taxonomy->cap);

			//Skip "link_category" because its only cap (manage_links) is already part of a default category.
			if (
				($taxonomy->name === 'link_category')
				&& ($permissions['manage_terms'] === 'manage_links')
				&& (count(array_unique($permissions)) === 1)
			) {
				continue;
			}

			//Skip "nav_menu" and "post_format" because they're intended for internal use and have the same
			//caps as the "Category" taxonomy.
			if ( in_array($taxonomy->name, ['nav_menu', 'post_format']) && $taxonomy->_builtin ) {
				continue;
			}

			$componentId = null;
			$isBuiltIn = isset($taxonomy->_builtin) && $taxonomy->_builtin;
			if ( $isBuiltIn ) {
				$componentId = self::CORE_COMPONENT_ID;
			} else if ( isset($this->taxonomyRegistrants[$taxonomy->name]) ) {
				$componentId = $this->taxonomyRegistrants[$taxonomy->name];
			}

			$label = $taxonomy->name;
			if ( isset($taxonomy->labels, $taxonomy->labels->name) && !empty($taxonomy->labels->name) ) {
				$label = $taxonomy->labels->name;
			}

			$uniqueLabel = $label;
			if ( isset($usedLabels[$uniqueLabel]) && !$isBuiltIn ) {
				$uniqueLabel = str_replace('_', ' ', $taxonomy->name);
			}
			//We want the label in lowercase unless it's an acronym.
			if ( !preg_match('@([A-Z]{2}|[a-z][A-Z])@', $uniqueLabel) ) {
				$uniqueLabel = strtolower($uniqueLabel);
			}
			$usedLabels[$uniqueLabel] = true;

			$registeredTaxonomies[$taxonomy->name] = [
				'name'        => $taxonomy->name,
				'label'       => $label,
				'pluralLabel' => $uniqueLabel,
				'componentId' => $componentId,
				'permissions' => $permissions,
			];

		}

		return $registeredTaxonomies;
	}

	protected function analysePostTypes($postTypes) {
		//Record which components use which CPT capabilities.
		foreach ($postTypes as $name => $postType) {
			if ( empty($postType['componentId']) || !isset($this->knownComponents[$postType['componentId']]) ) {
				continue;
			}
			$this->knownComponents[$postType['componentId']]->registeredPostTypes[$name] = true;
			foreach ($postType['permissions'] as $action => $capability) {
				if ( isset($this->capabilities[$capability]) ) {
					$this->capabilities[$capability]->addUsage($postType['componentId']);
				}
			}

			//Add a CPT category to the component that created this post type.
			if ( empty($postType['isDefault']) && ($postType['componentId'] !== self::CORE_COMPONENT_ID) ) {
				$componentRoot = $this->getComponentCategory($postType['componentId']);
				if ( $componentRoot ) {
					$category = new ameRexPostTypeCategory(
						$postType['label'],
						$postType['componentId'],
						$name,
						$postType['permissions']
					);
					$componentRoot->subcategories[] = $category;
				}
			}
		}
	}

	protected function analyseTaxonomies($taxonomies) {
		//Record taxonomy components and create taxonomy categories for those components.
		foreach ($taxonomies as $name => $taxonomy) {
			if ( empty($taxonomy['componentId']) ) {
				continue;
			}
			foreach ($taxonomy['permissions'] as $action => $capability) {
				if ( isset($this->capabilities[$capability]) ) {
					$this->capabilities[$capability]->addUsage($taxonomy['componentId']);
				}
			}

			//Add a taxonomy category to the component that created this taxonomy.
			if ( $taxonomy['componentId'] !== self::CORE_COMPONENT_ID ) {
				$componentRoot = $this->getComponentCategory($taxonomy['componentId']);
				if ( $componentRoot ) {
					$category = new ameRexTaxonomyCategory(
						$taxonomy['label'],
						$taxonomy['componentId'],
						$name,
						$taxonomy['permissions']
					);
					$componentRoot->subcategories[] = $category;
				}
			}
		}
	}

	/**
	 * Assign each capability that has known component relationships to one specific component.
	 * Copy component-related context information to the capability.
	 */
	protected function assignCapabilitiesToComponents() {
		//Figure out which component each capability belongs to.
		foreach ($this->capabilities as $capability => $unusedValue) {
			$details = $this->capabilities[$capability];

			if ( empty($details->componentId) && !empty($details->usedByComponents) ) {
				//Sort related components by priority.
				if ( count($details->usedByComponents) > 1 ) {
					uksort($details->usedByComponents, [$this, 'compareComponents']);
				}
				//Pick the first component.
				$details->componentId = key($details->usedByComponents);
			}

			if ( !empty($details->componentId) ) {
				//Copy context information that's relevant to the selected component.
				if ( isset($details->componentContext[$details->componentId]) ) {
					$propertiesToCopy = ['permissions', 'documentationUrl', 'notes'];
					foreach ($propertiesToCopy as $property) {
						if ( isset($details->componentContext[$details->componentId][$property]) ) {
							$details->$property = $details->componentContext[$details->componentId][$property];
						}
					}
				}

				if ( $details->componentId !== self::CORE_COMPONENT_ID ) {
					//Add the capability to the component category unless it's already there.
					$category = $this->getComponentCategory($details->componentId);
					if ( $category !== null ) {
						if ( !$category->hasCapability($capability) ) {
							$category->capabilities[$capability] = true;
						}
						unset($this->uncategorizedCapabilities[$capability]);
					} else {
						//This should never happen. If the capability has a component ID, that component
						//should already be registered.
						trigger_error(sprintf(
							'[AME] Capability "%s" belongs to component "%s" but that component appears to be unknown.',
							$capability,
							$details->componentId
						), E_USER_WARNING);
						continue;
					}
				}
			}
		}
	}

	/**
	 * @return ameRexCategory
	 */
	private function loadCoreCategories() {
		$root = new ameRexCategory('Core', self::CORE_COMPONENT_ID);
		$root->slug = 'default/core';

		$lines = file_get_contents(__DIR__ . '/data/core-categories.txt');
		$lines = explode("\n", $lines);

		$currentCategory = new ameRexCategory('Placeholder', self::CORE_COMPONENT_ID);

		//Each category starts with a title. The title is followed by one or more indented lines listing
		//capability names, one capability per line. Blank lines are ignored.
		$lineNumber = 0;
		foreach ($lines as $line) {
			$lineNumber++;

			//Skip blank lines.
			$line = rtrim($line);
			if ( $line === '' ) {
				continue;
			}

			$firstChar = substr($line, 0, 1);
			if ( $firstChar === ' ' || $firstChar === "\t" ) {
				//Found a capability.
				$capability = trim($line);
				//Skip unassigned caps. Even core capabilities sometimes get removed as WP development continues.
				if ( isset($this->capabilities[$capability]) ) {
					$currentCategory->capabilities[$capability] = true;
				}
			} else {
				//Found a "Category title [optional slug]"
				if ( preg_match('@^(?P<title>[^\[]+)(?:\s+\[(?P<slug>[^]]+)])?\s*$@', $line, $matches) ) {
					//Save the previous category if it matched any capabilities.
					if ( count($currentCategory->capabilities) > 0 ) {
						$root->addSubcategory($currentCategory);
					}

					$title = trim($matches['title']);
					$slug = !empty($matches['slug']) ? trim($matches['slug']) : ('default/' . $title);

					$currentCategory = new ameRexCategory($title, self::CORE_COMPONENT_ID);
					$currentCategory->slug = $slug;
				}
			}
		}

		//Save the last category.
		if ( count($currentCategory->capabilities) > 0 ) {
			$root->addSubcategory($currentCategory);
		}

		return $root;
	}

	protected function analyseAdminMenuCapabilities() {
		$tree = $this->menuEditor->get_active_admin_menu_tree();
		if ( !empty($tree) ) {
			foreach ($tree as $item) {
				$this->analyseMenuItem($item);
			}
		}
	}

	protected function analyseMenuItem($item, $parent = null) {
		$capability = ameUtils::get($item, ['defaults', 'access_level']);

		if ( empty($item['custom']) && !empty($item['defaults']) && !empty($capability) && empty($item['separator']) ) {
			$defaults = $item['defaults'];
			$hook = get_plugin_page_hook(ameUtils::get($defaults, 'file', ''), ameUtils::get($defaults, 'parent', ''));

			$rawTitle = ameMenuItem::get($item, 'menu_title', '[Untitled]');
			$fullTitle = trim(strip_tags(ameMenuItem::remove_update_count($rawTitle)));
			if ( $parent ) {
				$parentTitle = ameMenuItem::remove_update_count(ameMenuItem::get($parent, 'menu_title', '[Untitled]'));
				$fullTitle = trim(strip_tags($parentTitle)) . ' → ' . $fullTitle;
			}

			$relatedComponents = [];
			if ( !empty($hook) ) {
				$reflections = AmeReflectionCallable::getHookReflections($hook);
				foreach ($reflections as $reflection) {
					$path = $reflection->getFileName();
					$componentId = $this->getComponentIdFromPath($path);
					if ( $componentId ) {
						$relatedComponents[$this->getComponentIdFromPath($path)] = true;
					}
				}
			}

			if ( isset($this->capabilities[$capability]) ) {
				$this->capabilities[$capability]->menuItems[] = $fullTitle;
				$this->capabilities[$capability]->addManyUsages($relatedComponents);
			}
		}

		if ( !empty($item['items']) ) {
			foreach ($item['items'] as $submenu) {
				$this->analyseMenuItem($submenu, $item);
			}
		}
	}

	protected function getComponentIdFromPath($absolutePath) {
		$component = ameUtils::getComponentFromPath($absolutePath);
		if ( $component ) {
			return $component['type'] . ':' . $component['path'];
		}
		return null;
	}

	protected function getRoleData() {
		$wpRoles = ameRoleUtils::get_roles();
		$roles = [];

		$usersByRole = count_users();

		foreach ($wpRoles->role_objects as $roleId => $role) {
			$capabilities = [];
			if ( !empty($role->capabilities) && is_array($role->capabilities) ) {
				$capabilities = $this->menuEditor->castValuesToBool($role->capabilities);
			}

			$hasUsers = false;
			if ( isset($usersByRole['avail_roles'], $usersByRole['avail_roles'][$roleId]) ) {
				$hasUsers = ($usersByRole['avail_roles'][$roleId] > 0);
			}

			//Our JS expects the capability map to be an object. It must be an object
			//even if it's empty or if all of the keys (i.e. capability names) are numeric.
			$capabilities = (object)$capabilities;

			$roles[] = [
				'name'         => $roleId,
				'displayName'  => ameUtils::get($wpRoles->role_names, $roleId, $roleId),
				'capabilities' => $capabilities,
				'hasUsers'     => $hasUsers,
			];
		}
		return $roles;
	}

	/**
	 * Get a list of all known capabilities that apply to the current WordPress install.
	 *
	 * @param WP_User[] $users List of zero or more users.
	 * @return array Associative array indexed by capability name.
	 */
	protected function getAllCapabilities($users = []) {
		//Always include capabilities that are built into WordPress.
		$capabilities = $this->getDefaultCapabilities();

		//Add capabilities assigned to roles.
		$capabilities = $capabilities + ameRoleUtils::get_all_capabilities(is_multisite());

		//Add capabilities of users.
		$roleNames = ameRoleUtils::get_role_names();
		foreach ($users as $user) {
			$userCaps = $user->caps;
			//Remove roles from the capability list.
			$userCaps = array_diff_key($userCaps, $roleNames);
			$capabilities = $capabilities + $userCaps;
		}

		//Add custom capabilities that were created by the user. These persist until manually deleted.
		$capabilities = $capabilities + $this->getUserDefinedCaps();

		$capabilities = $this->menuEditor->castValuesToBool($capabilities);

		uksort($capabilities, 'strnatcasecmp');
		return $capabilities;
	}

	public function getDefaultCapabilities() {
		static $defaults = null;
		if ( $defaults !== null ) {
			return $defaults;
		}

		$defaults = self::loadCapabilities('default-capabilities.txt');

		if ( is_multisite() ) {
			$defaults = array_merge($defaults, $this->getMultisiteOnlyCapabilities());
		}

		return $defaults;
	}

	protected function getMultisiteOnlyCapabilities() {
		static $cache = null;
		if ( $cache === null ) {
			$cache = self::loadCapabilities('default-multisite-capabilities.txt');
		}
		return $cache;
	}

	/**
	 * Load a list of capabilities from a text file.
	 *
	 * @param string $fileName
	 * @param bool|int|string $fillValue Optional. Fill the result array with this value. Defaults to false.
	 * @return array Associative array with capability names as keys and $fillValue as values.
	 */
	public static function loadCapabilities($fileName, $fillValue = false) {
		$fileName = __DIR__ . '/data/' . $fileName;
		if ( !is_file($fileName) || !is_readable($fileName) ) {
			return [];
		}

		$contents = file_get_contents($fileName);

		$capabilities = preg_split('@[\r\n]+@', $contents);
		$capabilities = array_map('trim', $capabilities);
		$capabilities = array_filter($capabilities, [__CLASS__, 'isNotEmptyString']);
		$capabilities = array_filter($capabilities, [__CLASS__, 'isNotLineComment']);

		$capabilities = array_fill_keys($capabilities, $fillValue);

		return $capabilities;
	}

	protected static function isNotEmptyString($input) {
		return $input !== '';
	}

	protected static function isLineComment($input) {
		$input = trim($input);
		if ( $input === '' ) {
			return false;
		}

		$firstChar = substr($input, 0, 1);
		if ( $firstChar === '#' || $firstChar === ';' ) {
			return true;
		}

		if ( substr($input, 0, 2) === '//' ) {
			return true;
		}

		return false;
	}

	protected static function isNotLineComment($input) {
		return !self::isLineComment($input);
	}

	/**
	 * Get components, capability metadata and possible categories from the capability database.
	 */
	private function queryCapabilityDatabase() {
		$engine = new ameRexCapabilityInfoSearch();

		$engine->addDataSource(new ameRexSqliteDataSource(__DIR__ . '/data/capability-excerpt.sqlite3'));
		//$engine->addDataSource(new ameRexJsonCapabilityDataSource(__DIR__ . '/data/capability-metadata.json'));

		$results = $engine->query(array_keys($this->capabilities), $this->knownComponents);
		foreach ($results as $capability => $components) {
			$this->capabilities[$capability]->addManyUsages($components);
		}
	}

	private function compareComponents($idA, $idB) {
		$a = $this->knownComponents->components[$idA];
		$b = $this->knownComponents->components[$idB];

		if ( $a->isActive && !$b->isActive ) {
			return -1;
		}
		if ( $b->isActive && !$a->isActive ) {
			return 1;
		}
		if ( $a->isInstalled && !$b->isInstalled ) {
			return -1;
		}
		if ( $b->isInstalled && !$a->isInstalled ) {
			return 1;
		}

		return ($b->activeInstalls - $a->activeInstalls);
	}

	/**
	 * @param string $id
	 * @param WP_Post_Type $postType
	 */
	public function recordPostTypeOrigin($id, $postType) {
		if ( !is_admin() || empty($postType) || empty($id) ) {
			return;
		}

		if ( isset($postType->_builtin) && $postType->_builtin ) {
			return;
		}

		//Find the last entry that is part of a plugin or theme.
		$component = $this->detectCallerComponent();
		if ( $component !== null ) {
			$this->postTypeRegistrants[$id] = $component;
		}
	}

	public function recordTaxonomyOrigin(
		$id,
		/** @noinspection PhpUnusedParameterInspection It's part of the filter signature. We can't remove it. */
		$objectType,
		$taxonomy = []
	) {
		if ( !is_admin() || empty($taxonomy) || empty($id) || !is_array($taxonomy) ) {
			return;
		}

		if ( isset($taxonomy['_builtin']) && $taxonomy['_builtin'] ) {
			return;
		}

		$component = $this->detectCallerComponent();
		if ( $component !== null ) {
			$this->taxonomyRegistrants[$id] = $component;
		}
	}

	/**
	 * Detect the plugin or theme that triggered the current hook.
	 * If multiple components are involved, only the earliest one will be returned
	 * (i.e. the one at the bottom of the call stack).
	 *
	 * @return null|string
	 */
	private function detectCallerComponent() {
		$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
		//Drop the first three entries because they just contain this method, its caller,
		//and an apply_filters or do_action call.
		array_shift($trace);
		array_shift($trace);
		array_shift($trace);

		//Find the last entry that is part of a plugin or theme.
		$component = null;
		foreach ($trace as $item) {
			if ( empty($item['file']) ) {
				continue;
			}

			$possibleComponent = $this->getComponentIdFromPath($item['file']);
			if ( $possibleComponent ) {
				$component = $possibleComponent;
			}
		}
		return $component;
	}

	private function queryInstalledComponents() {
		$installedPlugins = get_plugins();
		foreach ($installedPlugins as $pluginFile => $plugin) {
			$pathComponents = explode('/', $pluginFile, 2);
			if ( count($pathComponents) < 2 ) {
				continue;
			}
			$component = new ameRexComponent(
				'plugin:' . $pathComponents[0],
				ameUtils::get($plugin, 'Name', $pathComponents[0])
			);
			$component->isInstalled = true;
			$component->isActive = is_plugin_active($pluginFile);
			$this->knownComponents[$component->id] = $component;
		}

		$installedMuPlugins = get_mu_plugins();
		foreach ($installedMuPlugins as $pluginFile => $plugin) {
			$component = new ameRexComponent(
				'mu-plugin:' . $pluginFile,
				ameUtils::get($plugin, 'Name', $pluginFile)
			);
			$component->isInstalled = true;
			$component->isActive = true; //mu-plugins are always active.
			$this->knownComponents[$component->id] = $component;
		}

		$activeThemeSlugs = [get_stylesheet(), get_template()];
		foreach ($activeThemeSlugs as $slug) {
			$componentId = 'theme:' . $slug;
			if ( isset($this->knownComponents[$componentId]) ) {
				continue;
			}
			$theme = wp_get_theme($slug);
			if ( !empty($theme) ) {
				$component = new ameRexComponent($componentId, $theme->get('Name'));
				$component->isActive = true;
				$component->isInstalled = true;
				$this->knownComponents[$component->id] = $component;
			}
		}
	}

	/**
	 * @param $componentId
	 * @return ameRexCategory|null
	 */
	private function getComponentCategory($componentId) {
		if ( !isset($this->componentRootCategories[$componentId]) ) {
			if ( !isset($this->knownComponents[$componentId]) ) {
				return null;
			}
			$category = new ameRexCategory($this->knownComponents[$componentId]->name, $componentId);
			$category->slug = 'components/' . $componentId;
			$this->componentRootCategories[$componentId] = $category;
		}
		return $this->componentRootCategories[$componentId];
	}

	/**
	 * Group capabilities that look like they belong to a post type but are not used by any registered post types.
	 * This could be stuff left behind by an uninstalled plugin, or just a set of similar capabilities.
	 *
	 * @return ameRexCategory[]
	 */
	protected function findProbablePostTypeCategories() {
		$potentialPostTypes = [];
		$foundCategories = [];

		//At the moment, WordPress database schema limits post types to 20 characters.
		$namePattern = '(?P<post_type>.{1,20}?)s?';
		$cptPatterns = [
			'@^edit_(?:(?:others|private|published)_)?' . $namePattern . '$@',
			'@^delete_(?:(?:others|private|published)_)?' . $namePattern . '$@',
			'@^publish_' . $namePattern . '$@',

			'@^read_private_' . $namePattern . '$@',
			'@^read_' . $namePattern . '$@',

			//WooCommerce stuff
			'@^(assign|edit|manage|delete)_' . $namePattern . '_terms$@',
		];

		foreach ($this->uncategorizedCapabilities as $capability => $unused) {
			foreach ($cptPatterns as $pattern) {
				if ( preg_match($pattern, $capability, $matches) ) {
					$postType = $matches['post_type'];

					//Unknown CPT-like capability.
					if ( !isset($potentialPostTypes[$postType]) ) {
						$potentialPostTypes[$postType] = [];
					}
					$potentialPostTypes[$postType][$capability] = $capability;

					break;
				}
			}
		}

		//Empirically, real post types have at least 3 associated capabilities.
		foreach ($potentialPostTypes as $postType => $typeCaps) {
			if ( count($typeCaps) >= 3 ) {
				//Note that this group does not correspond to an existing post type. It's just a set of similar caps.
				$title = ameUtils::ucWords(str_replace('_', ' ', $postType));
				if ( substr($title, -1) !== 's' ) {
					$title .= 's'; //Post type titles are usually plural.
				}

				$category = new ameRexCategory($title);
				$category->capabilities = array_fill_keys($typeCaps, true);
				$foundCategories[] = $category;

				//Now that we know which group these caps belong to, remove them from consideration.
				foreach ($typeCaps as $capability) {
					unset($this->uncategorizedCapabilities[$capability]);
				}
			}
		}

		return $foundCategories;
	}

	private function groupSimilarCapabilities() {
		$stopWords = $this->getPrefixStopWords();

		//Find the common prefix of each component root, if there is one.
		foreach ($this->componentRootCategories as $category) {
			if ( !empty($category->capabilities) && (count($category->capabilities) > 1) ) {
				$possiblePrefix = key($category->capabilities);
				foreach ($category->capabilities as $capability => $unusedValue) {
					for ($i = 0; $i < min(strlen($possiblePrefix), strlen($capability)); $i++) {
						if ( $possiblePrefix[$i] !== $capability[$i] ) {
							if ( $i >= 1 ) {
								$possiblePrefix = substr($possiblePrefix, 0, $i);
							} else {
								$possiblePrefix = '';
							}
							break;
						}
					}

					if ( $possiblePrefix === '' ) {
						break;
					}
				}

				//The prefix must be at least 2 characters long and must not consist entirely of stopwords.
				if ( strlen($possiblePrefix) >= 2 ) {
					$tokens = $this->tokenizeCapability($possiblePrefix);
					$foundStopWords = 0;
					foreach ($tokens as $token) {
						if ( isset($stopWords[strtolower($token)]) ) {
							$foundStopWords++;
						}
					}
					if ( $foundStopWords === count($tokens) ) {
						continue;
					}

					$prefix = implode(' ', array_slice($tokens, 0, 2));
					$this->componentCapPrefixes[$prefix] = $category;
				}
			}
		}

		$possibleCategories = [];
		foreach ($this->uncategorizedCapabilities as $capability => $unusedValue) {
			$tokens = $this->tokenizeCapability($capability);
			$upperLimit = min(2, count($tokens) - 1);

			$prefix = null;
			for ($i = 0; $i < $upperLimit; $i++) {
				if ( $prefix === null ) {
					$prefix = $tokens[$i];
				} else {
					$prefix .= ' ' . $tokens[$i];
				}
				if ( isset($stopWords[$tokens[$i]]) || (strlen($tokens[$i]) < 2) ) {
					continue;
				}

				//Check if one of the existing component categories has the same prefix
				//and add this capability there.
				if ( isset($this->componentCapPrefixes[$prefix]) ) {
					$this->componentCapPrefixes[$prefix]->addCapabilityToDefaultLocation($capability);
					unset($this->uncategorizedCapabilities[$capability]);

					$componentId = $this->componentCapPrefixes[$prefix]->componentId;
					if ( $componentId !== null ) {
						$this->capabilities[$capability]->addUsage($componentId);
						if ( empty($this->capabilities[$capability]->componentId) ) {
							$this->capabilities[$capability]->componentId = $componentId;
						}
					}
				}

				if ( !isset($possibleCategories[$prefix]) ) {
					$possibleCategories[$prefix] = [];
				}
				$possibleCategories[$prefix][$capability] = true;
			}
		}

		uasort($possibleCategories, [$this, 'compareArraySizes']);

		$approvedCategories = [];
		foreach ($possibleCategories as $prefix => $capabilities) {
			$capabilities = array_intersect_key($capabilities, $this->uncategorizedCapabilities);
			if ( count($capabilities) < 3 ) {
				continue;
			}

			$title = $prefix;
			//Convert all-lowercase to Title Case, but preserve stuff that already has mixed case.
			if ( strtolower($title) === $title ) {
				$title = ameUtils::ucWords($title);
			}

			//No vowels = probably an acronym.
			if ( !preg_match('@[aeuio]@', $title) ) {
				$title = strtoupper($title);
			}

			$category = new ameRexCategory($title);
			$category->capabilities = $capabilities;
			$approvedCategories[] = $category;
			foreach ($capabilities as $capability => $unused) {
				unset($this->uncategorizedCapabilities[$capability]);
			}
		}

		return $approvedCategories;
	}

	private function tokenizeCapability($capability) {
		return preg_split('@[\s_\-]@', $capability, -1, PREG_SPLIT_NO_EMPTY);
	}

	private function getPrefixStopWords() {
		static $stopWords = null;
		if ( $stopWords === null ) {
			$stopWords = [
				'edit',
				'delete',
				'add',
				'list',
				'manage',
				'read',
				'others',
				'private',
				'published',
				'publish',
				'terms',
				'view',
				'create',
				'settings',
				'options',
				'option',
				'setting',
				'update',
				'install',
			];
			$stopWords = array_fill_keys($stopWords, true);
		}
		return $stopWords;
	}

	private function compareArraySizes($a, $b) {
		return count($b) - count($a);
	}

	public function ajaxUpdateUserPreferences() {
		check_ajax_referer(self::UPDATE_PREFERENCES_ACTION);

		@header('Content-Type: application/json; charset=' . get_option('blog_charset'));
		if ( !$this->userCanAccessModule() ) {
			echo json_encode(['error' => 'Access denied']);
			exit;
		}

		$post = $this->menuEditor->get_post_params();
		if ( !isset($post['preferences']) || !is_string($post['preferences']) ) {
			echo json_encode(['error' => 'The "preferences" field is missing or invalid.']);
			exit;
		}

		$preferences = json_decode($post['preferences'], true);
		if ( $preferences === null ) {
			echo json_encode(['error' => 'The "preferences" field is not valid JSON.']);
			exit;
		}

		if ( !is_array($preferences) ) {
			echo json_encode(['error' => 'The "preferences" field is not valid. Expected an associative array.']);
			exit;
		}

		update_user_meta(get_current_user_id(), self::USER_PREFERENCE_KEY, json_encode($preferences));

		echo json_encode(['success' => true]);
		exit;
	}

	public function handleSettingsForm($post = []) {
		if ( !$this->userCanAccessModule() ) {
			wp_die("You don't have sufficient permissions to change these settings.");
		}

		$shouldUpdateNetwork = !empty($post['isGlobalUpdate']) && is_multisite() && is_super_admin();
		$redirectParams = [];
		if ( !empty($post['selectedActor']) ) {
			$redirectParams['selected_actor'] = strval($post['selectedActor']);
		}

		if ( !empty($post['addVisibleUsers']) && is_string($post['addVisibleUsers']) ) {
			$parsedUsers = $this->parseAdditionalVisibleUsers($post['addVisibleUsers']);
			if ( !empty($parsedUsers) ) {
				$redirectParams['addVisibleUsers'] = implode(',', $parsedUsers);
			}
		}

		$validator = $this->makeSettingsValidator($post['settings']);
		list($totalChanges,) = $this->applySettingsChanges($validator, $shouldUpdateNetwork, true);

		if ( $totalChanges <= 0 ) {
			$redirectParams['no-changes-made'] = 1;
			wp_safe_redirect($this->getTabUrl($redirectParams));
			exit;
		}

		$redirectParams['updated'] = 1;
		wp_safe_redirect($this->getTabUrl($redirectParams));
		exit;

		//TODO: Consider creating revisions in an update_option filter (flushed on shutdown), not here. Also in add_option.
		//TODO: Maybe leave revision log formatting until time of display. It can be built based on change lists, I think.

		//TODO: It's still useful to take a backup/create a revision before updating if there have been no backups in X days.
		//The roles could have changed while the plugin is inactive, in which case the previous revision could be out of date.

		//TODO: Save trashed roles.

		/*
		 * Remove all roles that don't exist in the role list.
			For each existing role:
				Add missing caps, remove deleted caps.
				Keep a list of caps created with AME even if they're unused.
			Create roles that don't exist.
			Rename roles where the current name doesn't match the new one.

			Update user capabilities.
			Update user roles (see validateUserRoleChange)

			(Seems straightforward. Could be done directly if API is too slow.)
			Validation:
				Only change editable roles.
				Some capabilities are only available to super admins.
				Cannot create capabilities with "<>&" characters.
				Validate roles names and display names.
				Don't delete the default role and used roles.
		 */
	}

	/**
	 * @param array|string $incomingSettings
	 * @param int $enabledChangeTypes
	 * @return ameRexSettingsValidator
	 */
	private function makeSettingsValidator(
		$incomingSettings,
		int $enabledChangeTypes = ameRexSettingsValidator::ALL_CHANGES
	): ameRexSettingsValidator {
		$moduleSettings = $this->loadSettings();

		return new ameRexSettingsValidator(
			$incomingSettings,
			$this->getAllCapabilities([wp_get_current_user()]),
			$this->getUserDefinedCaps(),
			ameUtils::get($moduleSettings, 'editableRoles', []),
			ameUtils::get($moduleSettings, 'userUiSettings', []),
			$this->getEffectiveEditableRoles(),
			$this->menuEditor,
			$enabledChangeTypes
		);
	}

	private function applySettingsChanges(
		ameRexSettingsValidator $validator,
		bool                    $shouldUpdateNetwork,
		                        $storeErrors = false
	): array {
		$errors = $validator->validate();
		if ( $storeErrors ) {
			$this->storeSettingsErrors($errors);
		}

		$totalChanges = $validator->getTotalChangeCount();
		if ( ($totalChanges <= 0) && !$shouldUpdateNetwork ) {
			return [0, $errors];
		}

		if ( $shouldUpdateNetwork && ($totalChanges < 1) ) {
			$totalChanges = 1; //A network update is always at least one change.
		}

		if ( $this->backupsEnabled ) {
			//Make a backup before actually changing anything.
			$currentUser = wp_get_current_user();
			$this->createRoleBackup(sprintf(
				'Automatic backup before applying %d changes made by %s',
				$totalChanges,
				$currentUser->user_login
			));
		}

		//Save user defined capabilities.
		if ( $validator->getUserDefinedCaps() !== null ) {
			$this->saveUserDefinedCaps($validator->getUserDefinedCaps());
		}

		//Apply role changes.
		//-------------------
		$wpRoles = ameRoleUtils::get_roles();

		//Delete roles.
		foreach ($validator->getRolesToDelete() as $id => $role) {
			$wpRoles->remove_role($id);
		}

		//Create roles.
		foreach ($validator->getRolesToCreate() as $id => $role) {
			$wpRoles->add_role($id, $role['displayName'], $role['capabilities']);
		}

		//Update role capabilities and display names.
		$rolesToModify = $validator->getRolesToModify();
		if ( !empty($rolesToModify) ) {
			foreach ($rolesToModify as $id => $role) {
				//Rename the role.
				if ( $wpRoles->roles[$id]['name'] !== $role['displayName'] ) {
					$wpRoles->roles[$id]['name'] = $role['displayName'];
				}
				$wpRoles->roles[$id]['capabilities'] = $role['capabilities'];
			}
			//Save role data.
			update_option($wpRoles->role_key, $wpRoles->roles);
		}

		//Apply role settings to all network sites if requested. We'll do that even if the settings
		//weren't changed, which lets you use this feature to normalize role settings across the network.
		if ( $shouldUpdateNetwork ) {
			$result = $this->updateNetworkRoles(get_option($wpRoles->role_key));
			if ( is_wp_error($result) ) {
				$errors[] = $result;
				if ( $storeErrors ) {
					$this->storeSettingsErrors($errors);
				}
			}
		}

		//Apply user changes.
		//-------------------

		foreach ($validator->getUsersToModify() as $userId => $modifiedUser) {
			$newCaps = $modifiedUser['capabilities'];
			$newRoles = $modifiedUser['roles'];
			$user = $validator->getExistingUser($userId);

			//We have to go through the trouble of removing/adding each role individually
			//because some plugins use the "add_user_role" and "remove_user_role" hooks.
			$oldRoles = (isset($user->roles) && is_array($user->roles)) ? $user->roles : [];
			$removedRoles = array_diff($oldRoles, $newRoles);
			$addedRoles = array_diff($newRoles, $oldRoles);
			foreach ($removedRoles as $role) {
				$user->remove_role($role);
			}
			foreach ($addedRoles as $role) {
				$user->add_role($role);
			}

			//Now that the necessary hooks have been triggered, we can just overwrite the caps array.
			//This is faster than calling add_cap() a bunch of times and it lets us precisely control
			//the order of roles in the array.
			$user->remove_all_caps();
			$user->roles = [];
			$user->caps = array_merge($newCaps, array_fill_keys($newRoles, true));

			update_user_meta($user->ID, $user->cap_key, $user->caps);
			$user->get_role_caps();
			$user->update_user_level_from_caps();
		}

		$this->loadSettings();
		$moduleSettingsChanged = false;

		//Save editable roles.
		if ( $validator->hasNewEditableRoleSettings() ) {
			$this->settings['editableRoles'] = $validator->getNewEditableRoleSettings();
			$moduleSettingsChanged = true;
		}

		//Save user UI extension settings.
		if ( $validator->hasNewUserUiSettings() ) {
			$this->settings['userUiSettings'] = $validator->getNewUserUiSettings();
			$moduleSettingsChanged = true;
		}

		if ( $moduleSettingsChanged ) {
			$this->saveSettings();
		}

		return [$totalChanges, $errors];
	}

	/**
	 * Update role settings across the entire Multisite network. Applies the same settings to all sites.
	 *
	 * @param mixed $roleData
	 * @return bool|WP_Error
	 */
	private function updateNetworkRoles($roleData) {
		global $wpdb;
		/** @var wpdb $wpdb */

		if ( !is_multisite() ) {
			return new WP_Error(
				'ame_not_multisite',
				'Cannot update roles on all sites because this is not a Multisite network.'
			);
		}

		if ( empty($roleData) ) {
			return new WP_Error('ame_invalid_role_data', 'Role data is invalid.');
		}

		if ( !function_exists('get_sites') ) {
			return new WP_Error(
				'ame_wp_incompatible',
				'The plugin does not support this feature in the current WordPress version.'
			);
		}

		//When a site uses an external object cache (e.g. Redis), we might need to remove the '$prefix_user_roles'
		//option from the cache to force the caching plugin to load the updated roles from the database.
		//This could be slow because it involves switching to each site, so it's off by default.
		$shouldClearCache = is_callable('wp_cache_switch_to_blog')
			&& is_callable('wp_using_ext_object_cache')
			&& wp_using_ext_object_cache()
			&& apply_filters('admin_menu_editor-clear_role_cache', false);
		$originalBlogId = is_callable('get_current_blog_id') ? get_current_blog_id() : null;

		$sites = get_sites([
			/*
			 * As of this writing, WP documentation doesn't mention any officially supported way
			 * to make get_sites() return all available results. There are unofficial workarounds,
			 * but simply specifying a very high number should be good enough for most situations.
			 * We'll probably hit the PHP execution time limit before we hit the result limit.
			 */
			'number' => 1000000,
			'fields' => 'ids',
		]);
		$serializedData = serialize($roleData);

		foreach ($sites as $siteId) {
			$prefix = $wpdb->get_blog_prefix($siteId);
			$tableName = $prefix . 'options';
			$optionName = $prefix . 'user_roles';

			$query = $wpdb->prepare(
				"UPDATE {$tableName} SET option_value = %s WHERE option_name = %s LIMIT 1",
				[$serializedData, $optionName]
			);

			$result = $wpdb->query($query);
			if ( $result === false ) {
				$errorMessage = sprintf('Failed to update site with ID %d.', $siteId);
				if ( !empty($wpdb->last_error) ) {
					$errorMessage .= ' Database error: ' . $wpdb->last_error;
				}
				return new WP_Error('ame_db_error', $errorMessage);
			}

			//Clear the option cache on the target site.
			if ( $shouldClearCache ) {
				wp_cache_switch_to_blog($siteId);
				wp_cache_delete($optionName, 'options');
			}
		}

		if ( $shouldClearCache && ($originalBlogId !== null) ) {
			wp_cache_switch_to_blog($originalBlogId);
		}

		return true;
	}

	/** @noinspection PhpUnusedPrivateMethodInspection */
	private function tempGenerateChangeSummary($role = null) {
		global $wpRoles;
		$id = '';

		if ( !empty($rolesToDelete) ) {
			$deletedIds = array_keys($rolesToDelete);
			if ( count($deletedIds) === 1 ) {
				$summary[] = 'Deleted ' . $deletedIds[0];
			} else if ( count($deletedIds) === 2 ) {
				$summary[] = 'Deleted ' . implode(' and ', $deletedIds);
			} else {
				$summary[] = 'Deleted ' . count($deletedIds) . ' roles';
			}
		}

		if ( !empty($rolesToCreate) ) {
			$createdIds = array_keys($rolesToCreate);
			$summary[] = 'Created ' . $this->formatPhraseList($createdIds, '%d roles');
		}

		$renamedRoles[] = sprintf('"%1$s" to %2$s', $wpRoles->roles[$id]['name'], $role['displayName']);

		$oldGrantedCaps = array_filter($wpRoles->roles[$id]['capabilities']);
		$newGrantedCaps = array_filter($role['capabilities']);
		$addedCaps = array_diff_key($newGrantedCaps, $oldGrantedCaps);
		$removedCaps = array_diff_key($oldGrantedCaps, $newGrantedCaps);

		$changes = [];
		if ( !empty($addedCaps) ) {
			$changes[] = count($addedCaps) . ' added';
		}
		if ( !empty($removedCaps) ) {
			$changes[] = count($removedCaps) . ' removed';
		}
		$capSummaries[] = $id . ' (' . implode(', ', $changes) . ')';

		//Add renames and cap changes to the summary.
		if ( !empty($renamedRoles) ) {
			$summary[] = 'Renamed ' . $this->formatPhraseList($renamedRoles, '%d roles', 1);
		}
		if ( !empty($capSummaries) ) {
			$summary[] = 'Changed capabilities: ' . implode(', ', $capSummaries);
		}
	}

	private function formatPhraseList($items, $combinedTemplate = '%d items', $limit = 2) {
		$itemCount = count($items);
		if ( $itemCount === 1 ) {
			return $items[0];
		} else if ( $itemCount === 2 ) {
			return implode(' and ', $items);
		} else if ( $itemCount <= $limit ) {
			return implode(', ', $items);
		}
		return sprintf($combinedTemplate, $itemCount);
	}

	/**
	 * @param array $errors
	 */
	private function storeSettingsErrors($errors) {
		if ( empty($errors) ) {
			delete_transient(self::SETTINGS_ERROR_TRANSIENT);
			return;
		}
		set_transient(self::SETTINGS_ERROR_TRANSIENT, $errors, 30 * 60);
		$this->cachedSettingsErrors = $errors;
	}

	/**
	 * @return array
	 */
	private function fetchSettingsErrors() {
		$storedErrors = get_transient(self::SETTINGS_ERROR_TRANSIENT);
		if ( $storedErrors !== false ) {
			delete_transient(self::SETTINGS_ERROR_TRANSIENT);
			$this->cachedSettingsErrors = (array)$storedErrors;
		}
		return $this->cachedSettingsErrors;
	}

	protected function getTemplateVariables($templateName) {
		return array_merge(
			parent::getTemplateVariables($templateName),
			[
				'settingsErrors'       => $this->fetchSettingsErrors(),
				'isProVersion'         => $this->menuEditor->is_pro_version(),
				'editableRolesFeature' => $this->getEditableRolesFeature(),
			]
		);
	}

	/**
	 * @param string $comment
	 * @param string|null $roleData
	 * @return bool
	 */
	private function createRoleBackup($comment = '', $roleData = null) {
		//TODO: If we're going to do backups, remember to create the table if it doesn't exist.
		//TODO: Delete the table when the plugin is uninstalled.
		if ( $roleData === null ) {
			$wpRoles = ameRoleUtils::get_roles();
			$roleData = get_option($wpRoles->role_key);
		}

		/** @var wpdb */
		global $wpdb;

		$result = $wpdb->insert(
			$this->backupTable,
			[
				'created_on' => gmdate('Y-m-d H:i:s'),
				'site_id'    => get_current_blog_id(),
				'user_id'    => get_current_user_id(),
				'role_data'  => serialize($roleData),
				'comment'    => $comment,
			],
			['%s', '%d', '%d', '%s', '%s']
		);
		return ($result !== false);
	}

	/**
	 * Delete old role data backups.
	 *
	 * We keep either the last 10 backups or the last 30 days of backups, whichever is greater.
	 */
	public function deleteOldRoleBackups() {
		/** @var wpdb $wpdb */
		global $wpdb;

		$nthItemDate = $wpdb->get_var(
			'SELECT created_on FROM ' . $this->backupTable
			. ' WHERE 1 ORDER BY created_on DESC LIMIT 1 OFFSET 10'
		);

		if ( empty($nthItemDate) ) {
			return; //There are 10 or fewer backups. Do nothing.
		}

		$survivalThreshold = gmdate(
			'Y-m-d H:i:s',
			min(strtotime($nthItemDate . ' UTC'), strtotime('-30 days'))
		);

		$wpdb->query($wpdb->prepare(
			'DELETE FROM ' . $this->backupTable .
			' WHERE created_on <= %s', $survivalThreshold
		));
	}

	/**
	 * Check if a user can access the role editor module.
	 *
	 * @param int|null $userId Optional user ID. Defaults to the current user.
	 * @return bool
	 */
	public function userCanAccessModule($userId = null) {
		if ( ($userId === null) || ($userId === get_current_user_id()) ) {
			return $this->menuEditor->current_user_can_edit_menu() && current_user_can(self::REQUIRED_CAPABILITY);
		}
		$user = get_user_by('id', $userId);
		if ( !$user ) {
			return false;
		}
		return $this->menuEditor->user_can_edit_menu($userId)
			&& $user->has_cap(self::REQUIRED_CAPABILITY);
	}

	private function saveUserDefinedCaps($capabilities) {
		delete_site_option(self::USER_DEFINED_CAP_KEY);

		if ( empty($capabilities) ) {
			return;
		}
		update_site_option(self::USER_DEFINED_CAP_KEY, $capabilities);
	}

	/**
	 * Get a list of capabilities that were created by the user(s).
	 *
	 * @return array [capabilityName => arbitraryValue]
	 */
	private function getUserDefinedCaps() {
		$caps = get_site_option(self::USER_DEFINED_CAP_KEY, []);
		if ( !is_array($caps) ) {
			return [];
		}
		return $caps;
	}

	private ?EditableRolesFeature $editableRolesFeature = null;
	private bool $triedLoadingEditableRolesFeature = false;

	private function getEditableRolesFeature(): ?EditableRolesFeature {
		if ( !$this->triedLoadingEditableRolesFeature ) {
			$this->triedLoadingEditableRolesFeature = true;
			$expectedPath = AME_ROOT_DIR . '/extras/modules/role-editor/editable-roles/editable-roles-feature.php';
			if ( $this->menuEditor->is_pro_version() && file_exists($expectedPath) ) {
				require_once $expectedPath;
				$this->editableRolesFeature = new EditableRolesFeature($this, $this->menuEditor);
			}
		}
		return $this->editableRolesFeature;
	}

	private function withEditableRolesFeature($callback, $defaultReturn = null) {
		$feature = $this->getEditableRolesFeature();
		if ( $feature !== null ) {
			return call_user_func($callback, $feature);
		}
		return $defaultReturn;
	}

	/**
	 * Get roles that the current user can edit in the role editor. Unlike get_editable_roles(), this method should
	 * include any special roles that do not show up in the role list when editing a user, like the forum roles
	 * created by bbPress.
	 *
	 * @return array
	 */
	public function getEffectiveEditableRoles() {
		$editableRoles = get_editable_roles();
		if ( empty($editableRoles) || !is_array($editableRoles) ) {
			$editableRoles = [];
		}
		if ( function_exists('bbp_get_dynamic_roles') ) {
			$userId = get_current_user_id();
			if (
				!isset($this->cachedOverallEditableRoleStrategy[$userId])
				|| ($this->cachedOverallEditableRoleStrategy[$userId] !== 'user-defined-list')
			) {
				$bbPressRoles = bbp_get_dynamic_roles();
				$editableRoles = array_merge($bbPressRoles, $editableRoles);
			}
		}
		return $editableRoles;
	}

	/**
	 * Prevent less-privileged users from editing more privileged users.
	 *
	 * @param array $requiredCaps List of primitive capabilities (output).
	 * @param string $capability The meta capability (input).
	 * @param int $thisUserId The user that's trying to do something.
	 * @param array $args
	 * @return array
	 */
	public function restrictUserEditing($requiredCaps, $capability, $thisUserId, $args) {
		static $editUserCaps = ['edit_user', 'delete_user', 'promote_user'];
		if ( !in_array($capability, $editUserCaps) || !isset($args[0]) ) {
			return $requiredCaps;
		}

		/** @var int $targetUserId The user that might be edited or deleted. */
		$targetUserId = intval($args[0]);

		$thisUserId = intval($thisUserId);
		$isMultisite = is_multisite();
		$isSuperAdmin = $isMultisite && is_super_admin($thisUserId);

		//Super Admins can edit everything.
		if ( $isSuperAdmin ) {
			return $requiredCaps;
		}

		$accessDenied = array_merge($requiredCaps, ['do_not_allow']);

		//Only Super Admins can edit other Super Admins.
		if ( $isMultisite && is_super_admin($targetUserId) && !$isSuperAdmin ) {
			return $accessDenied;
		}

		//Users that don't have access to the role editor can't edit users that do have access.
		if ( !$this->userCanAccessModule($thisUserId) && $this->userCanAccessModule($targetUserId) ) {
			return $accessDenied;
		}

		//Finally, a user can only edit those other users that have an editable role.
		//This part only works with the current user because get_editable_roles() does not take a user parameter.
		if ( ($thisUserId === get_current_user_id()) && ($thisUserId !== $targetUserId) ) {
			//The file that defines get_editable_roles() is only loaded in the admin back-end even though
			//the "edit_user" capability is also used in the front-end, e.g. when adding an "Edit" link
			//to the Toolbar/Admin Bar on author pages.
			if ( !function_exists('get_editable_roles') ) {
				return $requiredCaps;
			}
			$editableRoles = get_editable_roles();

			$strategy = $this->withEditableRolesFeature(
			//The default strategy is "auto".
				fn(EditableRolesFeature $feature) => $feature->getCachedStrategy($thisUserId, 'auto'),
				//If the editable roles feature is not available, default to "none" instead.
				//It's less disruptive, which is better when the user can't configure the strategy.
				'none'
			);

			//Don't apply any further restrictions if all editable role settings are set to "leave unchanged"
			//for this user.
			if ( $strategy === 'none' ) {
				return $requiredCaps;
			}

			if ( function_exists('bbp_get_dynamic_roles') ) {
				$bbPressRoles = bbp_get_dynamic_roles();
			} else {
				$bbPressRoles = [];
			}

			$targetUser = get_user_by('id', $targetUserId);
			$roles = (isset($targetUser->roles) && is_array($targetUser->roles)) ? $targetUser->roles : [];
			foreach ($roles as $roleId) {
				/*
				 * = bbPress compatibility fix =
				 *
				 * bbPress always removes its special roles (like "Participant") from the editable role list. As far
				 * as I can tell, the intent is to prevent people from bypassing bbPress settings and manually giving
				 * those roles to users.
				 *
				 * This should not automatically prevent administrators from editing users who have any bbPress roles.
				 * Therefore, let's ignore bbPress roles here unless the user has custom editable role settings.
				 */
				if ( array_key_exists($roleId, $bbPressRoles) && ($strategy !== 'user-defined-list') ) {
					continue;
				}

				if ( !array_key_exists($roleId, $editableRoles) ) {
					return $accessDenied;
				}
			}
		}

		return $requiredCaps;
	}

	public function ajaxGetDefaultRoles() {
		check_ajax_referer(self::GET_DEFAULT_ROLES_ACTION);

		if ( !$this->userCanAccessModule() ) {
			wp_send_json(['error' => 'Access denied']);
			exit;
		}

		$roleDataFile = __DIR__ . '/data/default-role-caps.json';
		if ( !file_exists($roleDataFile) ) {
			wp_send_json(['error' => 'Role data file not found']);
			exit;
		}

		//phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Local file.
		$fileContent = file_get_contents($roleDataFile);
		if ( $fileContent === false ) {
			wp_send_json(['error' => 'Failed to read role data file']);
			exit;
		}

		$roleData = json_decode($fileContent, true);
		if ( !is_array($roleData) ) {
			wp_send_json(['error' => 'Role data is not valid']);
			exit;
		}

		$installedWpVersion = get_bloginfo('version');

		$startTime = microtime(true);
		try {
			list($defaultRoles, $versionRange) = $this->findDefaultRolesForWpVersion(
				$roleData,
				$installedWpVersion
			);
		} catch (JsonReferenceException $e) {
			wp_send_json(['error' => 'Role data contains invalid JSON references']);
			exit;
		} catch (InvalidArgumentException $e) {
			wp_send_json(['error' => '(Exception) ' . $e->getMessage()]);
			exit;
		}
		$findTime = microtime(true) - $startTime;

		if ( !$defaultRoles ) {
			wp_send_json([
				'error' => 'This plugin does not have the default role data for the installed' .
					' WordPress version, or for any sufficiently close version.',
			]);
			exit;
		}

		//Convert the role collection to the format expected by the script.
		$convertedRoleCollection = [];
		foreach ($defaultRoles as $name => $role) {
			$convertedRoleCollection[] = [
				'name'         => $name,
				'displayName'  => $role['name'],
				'capabilities' => !empty($role['capabilities']) ? $role['capabilities'] : [],
			];
		}

		wp_send_json([
			'success'            => true,
			'installedWpVersion' => $installedWpVersion,
			'defaultRoles'       => $convertedRoleCollection,
			'wpVersionRange'     => $versionRange,
			'findTimeSeconds'    => $findTime,
		], null, JSON_PRETTY_PRINT);
		exit;
	}

	/**
	 * @param array $roleData
	 * @param string $installedWpVersion
	 * @return array [defaultRoles, versionRange]
	 * @throws JsonReferenceException
	 */
	private function findDefaultRolesForWpVersion($roleData, $installedWpVersion) {
		//Load the JSON reference helper. The role data can contain references to other parts of the document.
		require_once AME_ROOT_DIR . '/extras/JsonReferenceHelper.php';

		//The WP version number should *always* be a string, but plugins can change it and break things.
		if ( !is_string($installedWpVersion) ) {
			throw new InvalidArgumentException('Invalid WordPress version: not a string');
		}

		//Do we perchance have role data for this exact version? It's unlikely, but let's check.
		$exactMatch = JsonReferenceHelper::get($roleData, '/versions/' . $installedWpVersion, null);
		if ( $exactMatch !== null ) {
			return [
				$exactMatch,
				['start' => $installedWpVersion, 'end' => $installedWpVersion, 'matchType' => 'exact'],
			];
		}

		//Look for a version range that includes the installed version.
		if ( isset($roleData['ranges']) && is_array($roleData['ranges']) ) {
			foreach ($roleData['ranges'] as $range) {
				$start = $range['start'];
				$end = $range['end'];
				if (
					(version_compare($installedWpVersion, $start) >= 0)
					&& (version_compare($installedWpVersion, $end) <= 0)
				) {
					try {
						$defaultRoles = JsonReferenceHelper::maybeResolveReference($roleData, $range['roles']);
						if ( $defaultRoles !== null ) {
							return [$defaultRoles, ['start' => $start, 'end' => $end, 'matchType' => 'range']];
						}
					} catch (JsonReferenceException $e) {
						//Skip ranges that contain invalid references.
						continue;
					}
				}
			}
		}

		//Look for the most recent version that has the same major.minor numbers as the installed
		//version. Failing that, look for the most recent version that has the same major version.
		if ( !empty($roleData['versions']) && is_array($roleData['versions']) ) {
			$installedVersionParts = explode('.', $installedWpVersion);
			$installedMajorMinor = implode('.', array_slice($installedVersionParts, 0, 2));
			$installedMajor = isset($installedVersionParts[0]) ? $installedVersionParts[0] : '';

			$bestMajorMinorMatch = null;
			$bestMajorMatch = null;

			foreach ($roleData['versions'] as $version => $defaultRoles) {
				$versionParts = explode('.', $version);
				$majorMinor = $versionParts[0] . '.' . $versionParts[1];
				$major = $versionParts[0];

				if ( $majorMinor === $installedMajorMinor ) {
					if (
						($bestMajorMinorMatch === null)
						|| (version_compare($version, $bestMajorMinorMatch) > 0)
					) {
						$bestMajorMinorMatch = $version;
					}
				}

				if ( $major === $installedMajor ) {
					if (
						($bestMajorMatch === null)
						|| (version_compare($version, $bestMajorMatch) > 0)
					) {
						$bestMajorMatch = $version;
					}
				}
			}

			//Prefers the best match with the same major.minor version.
			if ( $bestMajorMinorMatch !== null ) {
				return [
					JsonReferenceHelper::maybeResolveReference(
						$roleData,
						$roleData['versions'][$bestMajorMinorMatch]
					),
					['start' => $bestMajorMinorMatch, 'end' => $bestMajorMinorMatch, 'matchType' => 'majorMinor'],
				];
			}

			if ( $bestMajorMatch !== null ) {
				return [
					JsonReferenceHelper::maybeResolveReference(
						$roleData,
						$roleData['versions'][$bestMajorMatch]
					),
					['start' => $bestMajorMatch, 'end' => $bestMajorMatch, 'matchType' => 'major'],
				];
			}
		}

		return [null, ['start' => $installedWpVersion, 'end' => $installedWpVersion, 'matchType' => 'none']];
	}

	public function getExportOptionLabel() {
		return 'Editable roles & User screens settings';
	}

	public function getExportableComponents(): array {
		return [
			$this->getModuleId()     => ameExportableComponent::builder($this->getExportOptionLabel())
				->description($this->getExportOptionDescription())
				->exportCallback([$this, 'exportSettings'])
				->importCallback([$this, 'importSettings'])
				->build(),
			'roles-and-capabilities' => ameExportableComponent::builder('Roles and capabilities')
				->advanced()
				->exportCallback(function () {
					require_once __DIR__ . '/role-export-feature.php';
					$transfer = RoleDataTransfer::create(ameRoleUtils::get_roles(), $this->menuEditor);
					return $transfer->serialize(true);
				})
				->importCallback(function ($newSettings, $configFieldValue) {
					if ( empty($configFieldValue) || !is_string($configFieldValue) ) {
						return ameImportResult::error('Role import configuration is invalid.');
					}

					//Parse the import configuration.
					$s = new SchemaFactory();
					$configSchema = $s->json(
						$s->struct([
							'roles'                => $s->arr($s->string()->min(1)->max(500))->max(500),
							'localOnlyCapStrategy' => $s->enum(['leaveUnchanged', 'disable']),
						])->required() //All fields are required.
					);
					$importConfig = $configSchema->parse($configFieldValue);
					if ( is_wp_error($importConfig) ) {
						return ameImportResult::fromWpError($importConfig);
					}

					$selectedRoles = $importConfig['roles'];
					$localOnlyCapStrategy = $importConfig['localOnlyCapStrategy'];

					if ( empty($selectedRoles) ) {
						return ameImportResult::nothing('No roles were selected for import.');
					}

					require_once __DIR__ . '/role-export-feature.php';
					try {
						$transfer = RoleDataTransfer::deserialize($newSettings);
					} catch (Exception $e) {
						return ameImportResult::error('Error parsing imported role data: ' . $e->getMessage());
					}

					$incomingCapabilitiesLookup = array_flip($transfer->getAllCapabilities());

					$importedRoles = array_intersect_key($transfer->getRoles(), array_flip($selectedRoles));
					$existingRoles = ameRoleUtils::get_roles();
					$newRoleSettings = [];

					foreach ($importedRoles as $name => $roleDetails) {
						$baseRole = $existingRoles->get_role($name);
						if ( !$baseRole ) {
							$capabilities = $roleDetails->getCapabilities();
						} else {
							$capabilities = $this->computeMergedCapabilities(
								$baseRole->capabilities,
								$roleDetails->getCapabilities(),
								$incomingCapabilitiesLookup,
								$localOnlyCapStrategy
							);
						}

						$newRoleSettings[] = [
							'name'         => $name,
							'displayName'  => $roleDetails->getDisplayName(),
							'capabilities' => $capabilities,
						];
					}

					$validator = $this->makeSettingsValidator(
						['roles' => $newRoleSettings],
						ameRexSettingsValidator::CREATE_ROLES | ameRexSettingsValidator::EDIT_ROLES
					);
					list($totalChanges, $errors) = $this->applySettingsChanges($validator, false, false);

					if ( $totalChanges === 0 ) {
						if ( empty($errors) ) {
							$result = ameImportResult::success('No changes were made');
						} else {
							$result = ameImportResult::error('Error importing roles');
						}
					} else {
						$message = sprintf('%d changes applied', $totalChanges);
						//In Multisite, we only apply the imported role settings to the current site.
						//The user can optionally update other sites via the "Roles" tab.
						if ( is_multisite() ) {
							$message .= '. (Note: Only the current site was updated. If you want to' .
								' apply these changes to all sites, go to the "Roles" tab and click "Update All Sites".)';
						}
						$result = ameImportResult::success($message);
					}
					$result->addErrors($errors);

					return $result;
				})
				->importConfigHtmlCallback(function ($pendingNewSettings, $configFieldName) {
					require_once __DIR__ . '/role-export-feature.php';
					try {
						$transfer = RoleDataTransfer::deserialize($pendingNewSettings);
					} catch (Exception $e) {
						return '<p class="ame-error-message">Error parsing imported role data: '
							. esc_html($e->getMessage()) . '</p>';
					}

					$scriptData = [
						'incomingRoleData' => $transfer->serialize(false),
						'coreCapabilities' => array_keys($this->getDefaultCapabilities()),
					];

					ScriptDependency::create(
						plugins_url('import-helper.js', __FILE__),
						'ame-role-import-helper',
						__DIR__ . '/import-helper.js',
						[
							'jquery-qtip',
							'ame-actor-manager',
							'ame-knockout',
							$this->registerRoleMergeScript(),
						]
					)
						->addJsVariable('wsAmeRoleImportHelperData', $scriptData)
						->setInFooter()
						->enqueue();

					$escapedFieldName = esc_attr($configFieldName);

					return <<<EOT
						<div id="ame-rex-import-roles-merge-container">
							<rex-role-merge-view params="model: importMergeVm"></rex-role-merge-view>
							<input type="hidden" name="$escapedFieldName" data-bind="value: configFieldValue" />
						</div>
EOT;
				})
				->build(),
		];
	}

	public function enqueueImportTabStyles() {
		wp_enqueue_auto_versioned_style(
			'ame-role-merge-styles',
			plugins_url('role-merge-styles.css', __FILE__)
		);
	}

	/**
	 * @param array<string,mixed> $baseCaps
	 * @param array<string,boolean> $incomingCaps
	 * @param array<string,mixed> $allIncomingCaps Indexed by capability name. All caps from the incoming data/imported file.
	 * @param string $localOnlyCapStrategy
	 * @return array<string,boolean>
	 */
	private function computeMergedCapabilities(
		array  $baseCaps,
		array  $incomingCaps,
		array  $allIncomingCaps,
		string $localOnlyCapStrategy
	): array {
		$mergedCaps = [];
		$relevantCaps = array_keys(array_merge($baseCaps, $incomingCaps));
		foreach ($relevantCaps as $capName) {
			$baseState = array_key_exists($capName, $baseCaps) ? boolval($baseCaps[$capName]) : null;
			$incomingState = array_key_exists($capName, $incomingCaps) ? boolval($incomingCaps[$capName]) : null;

			$isLocalOnlyCap = (
				array_key_exists($capName, $baseCaps)
				&& !array_key_exists($capName, $allIncomingCaps)
			);

			$action = 'acceptIncoming';
			if ( $isLocalOnlyCap ) {
				$action = $localOnlyCapStrategy;
			}

			switch ($action) {
				case 'leaveUnchanged':
					if ( $baseState !== null ) {
						$mergedCaps[$capName] = $baseState;
					}
					break;
				case 'disable':
					//No need to do anything, just leave the capability out of the merged list.
					break;
				case 'acceptIncoming':
				default:
					//Accept the incoming state. This is the default for most capabilities.
					if ( $incomingState !== null ) {
						$mergedCaps[$capName] = $incomingState;
					}
					break;
			}
		}

		return $mergedCaps;
	}

	public function maybeLoadUserUiExtensions() {
		if ( !current_user_can('edit_users') ) {
			return;
		}

		$extensionsSettings = ameUtils::get($this->loadSettings(), 'userUiSettings', []);
		new ameRexUserUiExtension($extensionsSettings, $this, $this->menuEditor);
	}

	/**
	 * Add a filter that ensures the selected user and any other temporarily visible users
	 * are included in the actor selector's user list.
	 *
	 * This is needed in two related scenarios:
	 * 1) When editing a specific user via a link from their profile page, that user will be set
	 *    as the "selected_actor" in the URL query string. But they might not be in the saved list
	 *    of visible users, so we need to add them temporarily.
	 * 2) When saving changes after editing a specific user, the user may select a different actor
	 *    before clicking "Save Changes". In that case, the edited user won't be the selected actor
	 *    any more, but they should still be visible in the list after the redirect.
	 */
	public function addTempVisibleUserFilter() {
		$extraUsersToLoad = [];

		$query = $this->menuEditor->get_query_params();
		//Always include the currently selected user, if any.
		if ( isset($query['selected_actor']) ) {
			$actorId = (string)$query['selected_actor'];
			if ( strpos($actorId, 'user:') === 0 ) {
				$userLogin = substr($actorId, strlen('user:'));
				if ( is_string($userLogin) && (strlen($userLogin) > 0) ) {
					$user = get_user_by('login', $userLogin);
					if ( $user ) {
						$extraUsersToLoad[] = $user->user_login;
					}
				}
			}
		}

		//If this request is part of the "save changes -> redirect back" flow, include any users
		//that were visible when the user clicked "Save Changes". This ensures that the user part
		//of the actor list looks the same after the redirect as it did before.
		if ( isset($query['addVisibleUsers']) ) {
			$moreUsers = $this->parseAdditionalVisibleUsers($query['addVisibleUsers']);
			$extraUsersToLoad = array_merge($extraUsersToLoad, $moreUsers);
		}

		//Just a precaution to avoid potential DoS by loading too many users.
		//Each user could potentially be an extra database query.
		$maxExtraUsers = 10;
		if ( count($extraUsersToLoad) > $maxExtraUsers ) {
			$extraUsersToLoad = array_slice($extraUsersToLoad, 0, $maxExtraUsers);
		}

		if ( !empty($extraUsersToLoad) ) {
			add_filter('admin_menu_editor-visible_users', function ($visibleUsers) use ($extraUsersToLoad) {
				return array_unique(array_merge($visibleUsers, $extraUsersToLoad));
			});
		}
	}

	/**
	 * Parse a list of user logins from a comma-separated string.
	 *
	 * Used to pass a list of users that should be visible after a "save changes" redirect.
	 * This typically only matters when editing a specific user via a link from their profile page.
	 * The current user doesn't need to be included in the list because they're always visible.
	 *
	 * Note: JSON would be more convenient here, but a JSON-encoded array of strings doesn't survive
	 * a trip through wp_safe_redirect(). A comma-separated list is more robust in this case.
	 *
	 * @param mixed $possibleUserList
	 * @return array
	 */
	private function parseAdditionalVisibleUsers($possibleUserList): array {
		if ( !is_string($possibleUserList) ) {
			return [];
		}

		$parsedUsers = explode(',', $possibleUserList);
		if ( !is_array($parsedUsers) ) {
			$parsedUsers = [];
		}
		$parsedUsers = array_filter(array_unique(array_map('strval', $parsedUsers)));

		//Remove the current user from the list.
		$currentUser = wp_get_current_user();
		if ( $currentUser ) {
			$parsedUsers = array_diff($parsedUsers, [$currentUser->user_login]);
		}

		//Re-index the array since some entries may have been removed.
		return array_values($parsedUsers);
	}
}

