<?php

declare(strict_types = 1);

namespace Drupal\ckeditor5;

use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\FilterFormatInterface;
use Psr\Log\LoggerInterface;

/**
 * Generates CKEditor 5 settings for existing text editors/formats.
 *
 * @internal
 *   This class may change at any time. It is not for use outside this module.
 */
final class SmartDefaultSettings {

  use StringTranslationTrait;

  /**
   * The CKEditor 5 plugin manager.
   *
   * @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
   */
  protected $pluginManager;

  /**
   * The module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * A logger instance.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * Constructs a SmartDefaultSettings object.
   *
   * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $plugin_manager
   *   The CKEditor 5 plugin manager.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   The current user.
   */
  public function __construct(CKEditor5PluginManagerInterface $plugin_manager, LoggerInterface $logger, ModuleHandlerInterface $module_handler, AccountInterface $current_user) {
    $this->pluginManager = $plugin_manager;
    $this->logger = $logger;
    $this->moduleHandler = $module_handler;
    $this->currentUser = $current_user;
  }

  /**
   * Computes the closest equivalent settings for switching to CKEditor 5.
   *
   * @param \Drupal\editor\EditorInterface|null $text_editor
   *   The editor being reconfigured for CKEditor 5; infer the settings based on
   *   the HTML restrictions.
   * @param \Drupal\filter\FilterFormatInterface $text_format
   *   The text format for which to compute smart default settings.
   *
   * @return array
   *   An array with two values:
   *   1. The cloned config entity with settings modified for CKEditor 5 … or a
   *      completely new config entity if this text format did not yet have one.
   *   2. Messages explaining what conclusions were reached.
   *
   * @throws \InvalidArgumentException
   *   Thrown when computing smart default settings for a new text format, or
   *   when the given text editor and format do not form a pair.
   */
  public function computeSmartDefaultSettings(?EditorInterface $text_editor, FilterFormatInterface $text_format): array {
    if ($text_format->isNew()) {
      throw new \InvalidArgumentException('Smart default settings can only be computed when there is a pre-existing text format.');
    }
    if ($text_editor && $text_editor->id() !== $text_format->id()) {
      throw new \InvalidArgumentException('The given text editor and text format must form a pair.');
    }

    $messages = [];

    // Ensure that unsaved changes to the text format object are also respected.
    if ($text_editor) {
      // Overwrite the Editor config entity object's $filterFormat property, to
      // prevent calls to Editor::hasAssociatedFilterFormat() and
      // Editor::getFilterFormat() from loading the FilterFormat from storage.
      // @todo Remove in https://www.drupal.org/project/drupal/issues/3231347.
      $reflector = new \ReflectionObject($text_editor);
      $property = $reflector->getProperty('filterFormat');
      $property->setValue($text_editor, $text_format);
    }

    // When there is a pre-existing text editor, pass that. Otherwise, generate
    // an empty shell of a text editor config entity — this will then
    // automatically get the default CKEditor 5 settings.
    // @todo Update after https://www.drupal.org/project/drupal/issues/3226673.
    /** @var \Drupal\editor\Entity\Editor $editor */
    $editor = $text_editor !== NULL
      ? clone $text_editor
      : Editor::create([
        'format' => $text_format->id(),
        // @see \Drupal\editor\Entity\Editor::__construct()
        // @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
        'editor' => 'ckeditor5',
      ]);
    $editor->setEditor('ckeditor5');

    $source_editing_additions = HTMLRestrictions::emptySet();
    // Compute the appropriate settings based on the HTML restrictions of the
    // text format.
    $old_editor = $editor->id() ? Editor::load($editor->id()) : NULL;
    $old_editor_restrictions = $old_editor ? HTMLRestrictions::fromTextFormat($old_editor->getFilterFormat()) : HTMLRestrictions::emptySet();
    // @todo Remove in https://www.drupal.org/project/drupal/issues/3245351
    if ($old_editor) {
      // When switching from another text editor, use CKEditor 5's default
      // settings.
      // @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
      $editor->setSettings(Editor::create(['editor' => 'ckeditor5'])->getSettings());
      $editor->setImageUploadSettings($old_editor->getImageUploadSettings());
    }

    // Add toolbar items based on HTML tags and attributes.
    // NOTE: Helper updates $editor->settings by reference and returns info for
    // the message.
    $result = $this->addToolbarItemsToMatchHtmlElementsInFormat($text_format, $editor);
    if ($result !== NULL) {
      [$enabling_message_content, $enabled_for_attributes_message_content, $missing, $plugins_enabled] = $result;

      // Distinguish between unsupported elements covering only tags or not.
      $missing_attributes = new HTMLRestrictions(array_filter($missing->getAllowedElements()));
      $unsupported = $missing->diff($missing_attributes);

      if ($enabling_message_content) {
        $this->logger->info('The CKEditor 5 migration enabled the following plugins to support tags that are allowed by the %text_format text format: %enabling_message_content. The text format must be saved to make these changes active.',
          [
            '%text_format' => $editor->getFilterFormat()->get('name'),
            '%enabling_message_content' => $enabling_message_content,
          ]
        );
      }

      // Warn user about unsupported tags.
      if (!$unsupported->allowsNothing()) {
        $this->addTagsToSourceEditing($editor, $unsupported);
        $source_editing_additions = $source_editing_additions->merge($unsupported);
        $this->logger->info("The following tags were permitted by the %text_format text format's filter configuration, but no plugin was available that supports them. To ensure the tags remain supported by this text format, the following were added to the Source Editing plugin's <em>Manually editable HTML tags</em>: @unsupported_string. The text format must be saved to make these changes active.", [
          '%text_format' => $editor->getFilterFormat()->get('name'),
          '@unsupported_string' => $unsupported->toFilterHtmlAllowedTagsString(),
        ]);
      }

      if ($enabled_for_attributes_message_content) {
        $this->logger->info('The CKEditor 5 migration process enabled the following plugins to support specific attributes that are allowed by the %text_format text format: %enabled_for_attributes_message_content.',
          [
            '%text_format' => $editor->getFilterFormat()->get('name'),
            '%enabled_for_attributes_message_content' => $enabled_for_attributes_message_content,
          ],
        );
      }
      // Warn user about supported tags but missing attributes.
      if (!$missing_attributes->allowsNothing()) {
        $this->addTagsToSourceEditing($editor, $missing_attributes);
        $source_editing_additions = $source_editing_additions->merge($missing_attributes);
        $this->logger->info("As part of migrating to CKEditor 5, it was found that the %text_format text format's HTML filters includes plugins that support the following tags, but not some of their attributes. To ensure these attributes remain supported, the following were added to the Source Editing plugin's <em>Manually editable HTML tags</em>: @missing_attributes. The text format must be saved to make these changes active.", [
          '%text_format' => $editor->getFilterFormat()->get('name'),
          '@missing_attributes' => $missing_attributes->toFilterHtmlAllowedTagsString(),
        ]);
      }
    }

    $has_html_restrictions = $editor->getFilterFormat()->filters('filter_html')->status;
    $missing_fundamental_tags = HTMLRestrictions::emptySet();
    if ($has_html_restrictions) {
      $fundamental = new HTMLRestrictions($this->pluginManager->getProvidedElements([
        'ckeditor5_essentials',
        'ckeditor5_paragraph',
      ]));
      $filter_html_restrictions = HTMLRestrictions::fromTextFormat($editor->getFilterFormat());
      $missing_fundamental_tags = $fundamental->diff($filter_html_restrictions);
      if (!$missing_fundamental_tags->allowsNothing()) {
        $editor->getFilterFormat()->setFilterConfig('filter_html', $filter_html_restrictions->merge($fundamental)->getAllowedElements());
        $this->logger->warning("As part of migrating the %text_format text format to CKEditor 5, the following tag(s) were added to <em>Limit allowed HTML tags and correct faulty HTML</em>, because they are needed to provide fundamental CKEditor 5 functionality : @missing_tags. The text format must be saved to make these changes active.", [
          '%text_format' => $editor->getFilterFormat()->get('name'),
          '@missing_tags' => $missing_fundamental_tags->toFilterHtmlAllowedTagsString(),
        ]);
      }
    }

    // Finally: for all enabled plugins, find the ones that are configurable,
    // and add their default settings. For enabled plugins with element subsets,
    // compute the appropriate settings to achieve the subset that matches the
    // original text format restrictions.
    $this->addDefaultSettingsForEnabledConfigurablePlugins($editor);

    if ($has_html_restrictions) {
      // Determine what tags/attributes are allowed in this text format that
      // were not allowed previous to the switch.
      $allowed_by_new_plugin_config = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($this->pluginManager->getEnabledDefinitions($editor)), $editor));
      $surplus_tags_attributes = $allowed_by_new_plugin_config->diff($old_editor_restrictions)->diff($missing_fundamental_tags);
      $attributes_to_tag = [];
      $added_tags = [];
      if (!$surplus_tags_attributes->allowsNothing()) {
        $surplus_elements = $surplus_tags_attributes->getAllowedElements();
        $added_tags = array_diff_key($surplus_elements, $old_editor_restrictions->getAllowedElements());
        foreach ($surplus_elements as $tag => $attributes) {
          $the_attributes = is_array($attributes) ? $attributes : [];
          foreach ($the_attributes as $attribute_name => $enabled) {
            if ($enabled) {
              $attributes_to_tag[$attribute_name][] = $tag;
            }
          }
        }
      }

      $help_enabled = $this->moduleHandler->moduleExists('help');
      $can_access_dblog = ($this->currentUser->hasPermission('access site reports') && $this->moduleHandler->moduleExists('dblog'));

      if (!empty($plugins_enabled) || !$source_editing_additions->allowsNothing()) {
        $beginning = $help_enabled ?
          $this->t('To maintain the capabilities of this text format, <a target="_blank" href=":ck_migration_url">the CKEditor 5 migration</a> did the following:', [
            ':ck_migration_url' => Url::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'migration-settings'])->toString(),
          ]) :
          $this->t('To maintain the capabilities of this text format, the CKEditor 5 migration did the following:');

        $plugin_info = !empty($plugins_enabled) ?
          $this->t('Enabled these plugins: (%plugins).', [
            '%plugins' => implode(', ', $plugins_enabled),
          ]) : '';

        $source_editing_info = '';
        if (!$source_editing_additions->allowsNothing()) {
          $source_editing_info = $help_enabled ?
            $this->t('Added these tags/attributes to the Source Editing Plugin\'s <a target="_blank" href=":source_edit_url">Manually editable HTML tags</a> setting: @tag_list',
              [
                '@tag_list' => $source_editing_additions->toFilterHtmlAllowedTagsString(),
                ':source_edit_url' => Url::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'source-editing'])->toString(),
              ]) :
            $this->t("Added these tags/attributes to the Source Editing Plugin's Manually editable HTML tags setting: @tag_list", ['@tag_list' => $source_editing_additions->toFilterHtmlAllowedTagsString()]);
        }

        $end = $can_access_dblog ?
          $this->t('Additional details are available <a target="_blank" href=":dblog_url">in your logs</a>.',
            [
              ':dblog_url' => Url::fromRoute('dblog.overview')
                ->setOption('query', ['type[]' => 'ckeditor5'])
                ->toString(),
            ]
          ) :
          $this->t('Additional details are available in your logs.');

        $messages[MessengerInterface::TYPE_STATUS][] = new FormattableMarkup('@beginning @plugin_info @source_editing_info. @end', [
          '@beginning' => $beginning,
          '@plugin_info' => $plugin_info,
          '@source_editing_info' => $source_editing_info,
          '@end' => $end,
        ]);
      }

      // Generate warning for:
      // - The addition of <p>/<br> due to them being fundamental tags.
      // - The addition of other tags/attributes previously unsupported by the
      //   format.
      if (!$missing_fundamental_tags->allowsNothing() || !empty($attributes_to_tag) || !empty($added_tags)) {
        $beginning = $this->t('Updating to CKEditor 5 added support for some previously unsupported tags/attributes.');
        $fundamental_tags = '';
        if ($help_enabled && !$missing_fundamental_tags->allowsNothing()) {
          $fundamental_tags = $this->formatPlural(count($missing_fundamental_tags->toCKEditor5ElementsArray()),
            'The @tag tag was added because it is <a target="_blank" href=":fundamental_tag_link">required by CKEditor 5</a>.',
            'The @tag tags were added because they are <a target="_blank" href=":fundamental_tag_link">required by CKEditor 5</a>.',
            [
              '@tag' => implode(', ', $missing_fundamental_tags->toCKEditor5ElementsArray()),
              ':fundamental_tag_link' => URL::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'required-tags'])->toString(),
            ]);
        }
        elseif (!$missing_fundamental_tags->allowsNothing()) {
          $fundamental_tags = $this->formatPlural(count($missing_fundamental_tags->toCKEditor5ElementsArray()),
            'The @tag tag was added because it is required by CKEditor 5.',
            'The @tag tags were added because they are required by CKEditor 5.',
            [
              '@tag' => implode(', ', $missing_fundamental_tags->toCKEditor5ElementsArray()),
            ]);
        }

        $added_elements_begin = !empty($attributes_to_tag) || !empty($added_tags) ? $this->t('A plugin introduced support for the following:') : '';
        $added_elements_tags = !empty($added_tags) ? $this->formatPlural(
          count($added_tags),
          'The tag %tags;',
          'The tags %tags;',
          [
            '%tags' => implode(', ', array_map(function ($tag_name) {
              return "<$tag_name>";
            }, array_keys($added_tags))),
          ]) : '';
        $added_elements_attributes = !empty($attributes_to_tag) ? $this->formatPlural(
          count($attributes_to_tag),
          'This attribute: %attributes;',
          'These attributes: %attributes;',
          [
            '%attributes' => rtrim(array_reduce(array_keys($attributes_to_tag), function ($carry, $item) use ($attributes_to_tag) {
              $for_tags = implode(', ', array_map(function ($item) {
                return "<$item>";
              }, $attributes_to_tag[$item]));
              return "$carry $item ({$this->t('for', [],  ['context' => 'Ckeditor 5 tag list'])} $for_tags),";
            }, ''), " ,"),
          ]
        ) : '';
        $end = $can_access_dblog ?
          $this->t('Additional details are available <a target="_blank" href=":dblog_url">in your logs</a>.',
            [
              ':dblog_url' => Url::fromRoute('dblog.overview')
                ->setOption('query', ['type[]' => 'ckeditor5'])
                ->toString(),
            ]
          ) :
          $this->t('Additional details are available in your logs.');

        $messages[MessengerInterface::TYPE_WARNING][] = new FormattableMarkup('@beginning @added_elements_begin @fundamental_tags @added_elements_tags @added_elements_attributes @end',
          [
            '@beginning' => $beginning,
            '@added_elements_begin' => $added_elements_begin,
            '@fundamental_tags' => $fundamental_tags,
            '@added_elements_tags' => $added_elements_tags,
            '@added_elements_attributes' => $added_elements_attributes,
            '@end' => $end,
          ]);
      }
    }

    return [$editor, $messages];
  }

  private function addTagsToSourceEditing(EditorInterface $editor, HTMLRestrictions $tags): array {
    $messages = [];
    $settings = $editor->getSettings();
    if (!isset($settings['toolbar']['items']) || !in_array('sourceEditing', $settings['toolbar']['items'])) {
      $messages[MessengerInterface::TYPE_STATUS][] = $this->t('The <em>Source Editing</em> plugin was enabled to support tags and/or attributes that are not explicitly supported by any available CKEditor 5 plugins.');
      // Add the "Source Editing" toolbar item in a new group.
      $settings['toolbar']['items'][] = '|';
      $settings['toolbar']['items'][] = 'sourceEditing';
    }
    $allowed_tags_array = $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] ?? [];
    $allowed_tags_string = implode(' ', $allowed_tags_array);
    $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = HTMLRestrictions::fromString($allowed_tags_string)->merge($tags)->toCKEditor5ElementsArray();
    $editor->setSettings($settings);
    return $messages;
  }

  /**
   * Computes net new needed elements when considering adding the given plugin.
   *
   * @param \Drupal\ckeditor5\HTMLRestrictions $baseline
   *   The set of HTML restrictions already supported.
   * @param \Drupal\ckeditor5\HTMLRestrictions $needed
   *   The set of HTML restrictions that are needed, that is: in addition to
   *   $baseline.
   * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $added_plugin
   *   The CKEditor 5 plugin that is being evaluated to check if it would meet
   *   some of the needs.
   *
   * @return array
   *   An array containing two values:
   *   - a set of HTML restrictions that indicates the net new additions that
   *     are needed
   *   - a set of HTML restrictions that indicates the surplus additions (these
   *     are elements that were not needed, but are added by this plugin)
   */
  private static function computeNetNewElementsForPlugin(HTMLRestrictions $baseline, HTMLRestrictions $needed, CKEditor5PluginDefinition $added_plugin): array {
    $plugin_support = HTMLRestrictions::fromString(implode(' ', $added_plugin->getElements()));
    // Do not inspect just $plugin_support, but the union of that with the
    // already supported elements: wildcard restrictions will only resolve
    // if the concrete tags they support are also present.
    $potential_future = $baseline->merge($plugin_support);
    // This is the heart of the operation: intersect the potential future
    // with what we need to achieve, then subtract what is already
    // supported. This yields the net new elements.
    $net_new = $potential_future->intersect($needed)->diff($baseline);
    // But … we may compute too many.
    $surplus_additions = $potential_future->diff($needed)->diff($baseline);
    return [$net_new, $surplus_additions];
  }

  /**
   * Computes a score for the given surplus compared to the given need.
   *
   * @param \Drupal\ckeditor5\HTMLRestrictions $surplus
   *   A surplus compared to what is needed.
   * @param \Drupal\ckeditor5\HTMLRestrictions $needed
   *   Exactly what is needed.
   *
   * @return int
   *   A surplus score. Lower is better. Scores are a positive integer.
   *
   * @see https://www.drupal.org/project/drupal/issues/3231328#comment-14444987
   */
  private static function computeSurplusScore(HTMLRestrictions $surplus, HTMLRestrictions $needed): int {
    // Compute a score for surplus elements, while taking into account how much
    // impact each surplus element has:
    $surplus_score = 0;
    foreach ($surplus->getAllowedElements() as $tag_name => $attributes_config) {
      // 10^6 per surplus tag.
      if (!isset($needed->getAllowedElements()[$tag_name])) {
        $surplus_score += pow(10, 6);
      }

      // 10^5 per surplus "any attributes allowed".
      if ($attributes_config === TRUE) {
        $surplus_score += pow(10, 5);
      }

      if (!is_array($attributes_config)) {
        continue;
      }

      foreach ($attributes_config as $attribute_name => $attribute_config) {
        // 10^4 per surplus wildcard attribute.
        if (str_contains($attribute_name, '*')) {
          $surplus_score += pow(10, 4);
        }
        // 10^3 per surplus attribute.
        else {
          $surplus_score += pow(10, 3);
        }

        // 10^2 per surplus "any attribute values allowed".
        if ($attribute_config === TRUE) {
          $surplus_score += pow(10, 2);
        }

        if (!is_array($attribute_config)) {
          continue;
        }

        foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
          // 10^1 per surplus wildcard attribute value.
          if (str_contains($allowed_attribute_value, '*')) {
            $surplus_score += pow(10, 1);
          }
          // 10^0 per surplus attribute value.
          else {
            $surplus_score += pow(10, 0);
          }
        }
      }
    }
    return $surplus_score;
  }

  /**
   * Finds candidates for the still needed restrictions among disabled plugins.
   *
   * @param \Drupal\ckeditor5\HTMLRestrictions $provided
   *   The already provided HTML restrictions, thanks to already enabled
   *   CKEditor 5 plugins.
   * @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
   *   The still needed HTML restrictions, unmet by the already enabled CKEditor
   *   5 plugins.
   * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $disabled_plugin_definitions
   *   The list of not yet enabled CKEditor 5 plugin definitions, amongst which
   *   candidates must be found.
   * @param \Drupal\editor\EditorInterface $prospective_editor
   *   The in-progress prospective editor to be generated by this class.
   *
   * @return array
   *   A nested array with a tree structure covering:
   *   1. tag name
   *   2. concrete attribute name, `-attribute-none-` (meaning no attributes
   *      allowed on this tag) or `-attribute-any-` (meaning any attribute
   *      allowed on this tag).
   *   3. (optional) attribute value (if concrete attribute name in previous
   *      level), `TRUE` or `FALSE`
   *   4. (optional) attribute value restriction
   *   5. candidate CKEditor 5 plugin ID for the HTML elements in the hierarchy
   *   and the surplus score as the value. In other words: the leaf of this is
   *   always a leaf, and a selected CKEditor 5 plugin ID is always the parent
   *   of a leaf.
   */
  private function getCandidates(HTMLRestrictions $provided, HTMLRestrictions $still_needed, array $disabled_plugin_definitions, EditorInterface $prospective_editor): array {
    $plugin_candidates = [];
    if (!$still_needed->allowsNothing()) {
      foreach ($disabled_plugin_definitions as $definition) {
        // Only proceed if the plugin has configured elements and the plugin
        // does not have conditions, or only conditions that are met. In the
        // future we could add support for automatically enabling filters, but
        // for now we assume that the filter configuration cannot be modified.
        if (!$definition->hasElements()) {
          continue;
        }
        if (!$definition->hasConditions()) {
          // Any plugin that has no conditions is a viable candidate.
          $is_viable_candidate = TRUE;
        }
        else {
          $is_viable_candidate = TRUE;
          foreach ($definition->getConditions() as $condition_type => $required_value) {
            // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::isPluginDisabled()
            // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
            $is_viable_candidate = match ($condition_type) {
              // If this requires a toolbar item that is provided by this
              // plugin, then this is a viable candidate: placing this plugin's
              // toolbar item suffices.
              'toolbarItem' => array_key_exists($definition->getConditions()['toolbarItem'], $definition->getToolbarItems()),
              // The image upload status is not modified, so if the current
              // value matches the condition, then this is a viable candidate.
              'imageUploadStatus' => ($prospective_editor->getImageUploadSettings()['status'] ?? FALSE) === $required_value,
              // The set of filters is not modified, so if the required filter
              // is already enabled, then this is a viable candidate.
              // @todo Simplify in https://www.drupal.org/project/drupal/issues/2385047
              'filter' => $prospective_editor->getFilterFormat()->filters()->has($required_value) && $prospective_editor->getFilterFormat()->filters()->get($required_value)->status,
              // The default configuration is used for each plugin that would be
              // enabled, so if the default configuration contains the required
              // configuration, then this is a viable candidate.
              'requiresConfiguration' =>  array_intersect($this->pluginManager->createInstance($definition->id())->defaultConfiguration(), $required_value) === $required_value,
              // If this requires plugins already in the current prospective
              // editor, then this is a viable candidate.
              'plugins' => array_intersect($required_value, array_keys($this->pluginManager->getEnabledDefinitions($prospective_editor))) === $required_value,
            };
            if (!$is_viable_candidate) {
              break;
            }
          }
        }
        if ($is_viable_candidate) {
          [$net_new, $surplus_additions] = self::computeNetNewElementsForPlugin($provided, $still_needed, $definition);
          if (!$net_new->allowsNothing()) {
            $plugin_id = $definition->id();
            $creatable_elements = HTMLRestrictions::fromString(implode(' ', $definition->getCreatableElements()));
            $surplus_score = static::computeSurplusScore($surplus_additions, $still_needed);
            foreach ($net_new->getAllowedElements() as $tag_name => $attributes_config) {
              // Non-specific attribute restrictions: `FALSE` or `TRUE`.
              // TRICKY: PHP does not support boolean array keys, so map these
              // to a string. The string must not be a valid attribute name, so
              // use a leading and trailing dash.
              if (!is_array($attributes_config)) {
                if ($attributes_config === FALSE && !array_key_exists($tag_name, $creatable_elements->getAllowedElements())) {
                  // If this plugin is not able to create the plain tag, then
                  // cannot be a candidate for the tag without attributes.
                  continue;
                }
                $non_specific_attribute = $attributes_config ? '-attributes-any-' : '-attributes-none-';
                $plugin_candidates[$tag_name][$non_specific_attribute][$plugin_id] = $surplus_score;
                continue;
              }

              // With specific attribute restrictions: array.
              foreach ($attributes_config as $attribute_name => $attribute_config) {
                if (!is_array($attribute_config)) {
                  $plugin_candidates[$tag_name][$attribute_name][$attribute_config][$plugin_id] = $surplus_score;
                }
                else {
                  foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
                    $plugin_candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config][$plugin_id] = $surplus_score;
                  }
                }
              }

              // If this plugin supports unneeded attributes, it still makes a
              // valid candidate for supporting the HTML tag.
              $plugin_candidates[$tag_name]['-attributes-none-'][$plugin_id] = $surplus_score;
            }
          }
        }
      }
    }

    return $plugin_candidates;
  }

  /**
   * Selects best candidate for each of the still needed restrictions.
   *
   * @param array $candidates
   *   The output of ::getCandidates().
   * @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
   *   The still needed HTML restrictions, unmet by the already enabled CKEditor
   *   5 plugins.
   * @param string[] $already_supported_tags
   *   A list of already supported HTML tags, necessary to select the best
   *   matching candidate for elements still needed in $still_needed.
   *
   * @return array
   *   A nested array with a tree structure, with each key a selected CKEditor 5
   *   plugin ID and its values expressing the reason it was enabled.
   */
  private static function selectCandidate(array $candidates, HTMLRestrictions $still_needed, array $already_supported_tags): array {
    assert(Inspector::assertAllStrings($already_supported_tags));

    // Make a selection in the candidates: minimize the surplus count, to
    // avoid generating surplus additions whenever possible.
    $selected_plugins = [];
    foreach ($still_needed->getAllowedElements() as $tag_name => $attributes_config) {
      if (!isset($candidates[$tag_name])) {
        // Sadly no plugin found for this tag.
        continue;
      }

      // Non-specific attribute restrictions for tag.
      if (is_bool($attributes_config)) {
        $key = $attributes_config ? '-attributes-any-' : '-attributes-none-';
        if (!isset($candidates[$tag_name][$key])) {
          // Sadly no plugin found for this tag + unspecific attribute.
          continue;
        }
        asort($candidates[$tag_name][$key]);
        $selected_plugin_id = array_keys($candidates[$tag_name][$key])[0];
        $selected_plugins[$selected_plugin_id][$key][$tag_name] = NULL;
        continue;
      }

      // Specific attribute restrictions for tag.
      foreach ($attributes_config as $attribute_name => $attribute_config) {
        if (!isset($candidates[$tag_name][$attribute_name])) {
          // Sadly no plugin found for this tag + attribute.
          continue;
        }
        if (!is_array($attribute_config)) {
          if (!isset($candidates[$tag_name][$attribute_name][$attribute_config])) {
            // Sadly no plugin found for this tag + attribute + config.
            continue;
          }
          asort($candidates[$tag_name][$attribute_name][$attribute_config]);
          $selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$attribute_config])[0];
          $selected_plugins[$selected_plugin_id][$attribute_name][$tag_name] = $attribute_config;
          continue;
        }
        else {
          foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
            if (!isset($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])) {
              // Sadly no plugin found for this tag + attr + value + config.
              continue;
            }
            asort($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config]);
            $selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])[0];
            $selected_plugins[$selected_plugin_id][$attribute_name][$tag_name][$allowed_attribute_value] = $allowed_attribute_value_config;
            continue;
          }
        }
      }

      // If we got to this point, no exact match was found. But selecting a
      // plugin to support the tag at all (when it is not yet supported) is
      // crucial to meet the user's expectations.
      // For example: when `<blockquote cite>` is needed, select at least the
      // plugin that can support `<blockquote>`, then only the `cite` attribute
      // needs to be made possible using the `SourceEditing` plugin.
      if (!in_array($tag_name, $already_supported_tags, TRUE) && isset($candidates[$tag_name]['-attributes-none-'])) {
        asort($candidates[$tag_name]['-attributes-none-']);
        $selected_plugin_id = array_keys($candidates[$tag_name]['-attributes-none-'])[0];
        $selected_plugins[$selected_plugin_id]['-attributes-none-'][$tag_name] = NULL;
      }
    }

    // The above selects all exact matches. It's possible the same plugin is
    // selected for multiple reasons: for supporting the tag at all, but also
    // for supporting more attributes on the tag. Whenever that scenario
    // occurs, keep only the "tag" reason, since that is the most relevant one
    // for the end user. Otherwise a single plugin being selected (and enabled)
    // could generate multiple messages, which would be confusing and
    // overwhelming for the user.
    // For example: when `<a href>` is needed, supporting `<a>` is more
    // relevant to be informed about as an end user than the plugin also being
    // enabled to support the `href` attribute.
    foreach ($selected_plugins as $selected_plugin_id => $reason) {
      if (count($reason) > 1 && isset($reason['-attributes-none-'])) {
        $selected_plugins[$selected_plugin_id] = array_intersect_key($reason, ['-attributes-none-' => TRUE]);
      }
    }

    return $selected_plugins;
  }

  /**
   * Adds CKEditor 5 toolbar items to match the format's HTML elements.
   *
   * @param \Drupal\filter\FilterFormatInterface $format
   *   The text format for which to compute smart default settings.
   * @param \Drupal\editor\EditorInterface $editor
   *   The text editor config entity to update.
   *
   * @return array|null
   *   NULL when nothing happened, otherwise an array with four values:
   *   1. a description (for use in a message) of which CKEditor 5 plugins were
   *      enabled to match the HTML tags allowed by the text format.
   *   2. a description (for use in a message) of which CKEditor 5 plugins were
   *      enabled to match the HTML attributes allowed by the text format.
   *   3. the unsupported elements, in an HTMLRestrictions value object.
   *   4. the list of enabled plugin labels.
   */
  private function addToolbarItemsToMatchHtmlElementsInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array {
    $html_restrictions_needed_elements = $format->getHtmlRestrictions();
    if ($html_restrictions_needed_elements === FALSE) {
      // There are no HTML restrictions, so configure CKEditor 5 to allow
      // arbitrary markup to be entered.
      $editor_settings_to_update = $editor->getSettings();
      // Create new group for all the added toolbar items.
      $editor_settings_to_update['toolbar']['items'][] = '|';
      $editor_settings_to_update['toolbar']['items'][] = 'sourceEditing';
      $editor_settings_to_update['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = [];
      $editor->setSettings($editor_settings_to_update);
      return [
        NULL,
        NULL,
        HTMLRestrictions::emptySet(),
        $this->pluginManager->getDefinition('ckeditor5_sourceEditing'),
      ];
    }

    $all_definitions = $this->pluginManager->getDefinitions();
    $enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
    $disabled_definitions = array_diff_key($all_definitions, $enabled_definitions);
    $enabled_plugins = array_keys($enabled_definitions);
    $provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins, $editor);
    $provided = new HTMLRestrictions($provided_elements);
    $needed = HTMLRestrictions::fromTextFormat($format);
    // Plugins only supporting <tag attr> cannot create the tag. For that, they
    // must support plain <tag> too. With this being the case, break down what
    // is needed based on what is currently provided.
    // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
    // TRICKY: the HTMLRestrictions value object can only convey complete
    // restrictions: merging <foo> and <foo bar> results in just <foo bar>. The
    // list of already provided plain tags must hence be constructed separately.
    $provided_plain_tags = new HTMLRestrictions(
      $this->pluginManager->getProvidedElements($enabled_plugins, NULL, FALSE, TRUE)
    );

    // Determine the still needed plain tags, the still needed attributes, and
    // the union of both.
    $still_needed_plain_tags = $needed->extractPlainTagsSubset()->diff($provided_plain_tags);
    $still_needed_attributes = $needed->diff($provided)->diff($still_needed_plain_tags);
    $still_needed = $still_needed_plain_tags->merge($still_needed_attributes);

    if (!$still_needed->allowsNothing()) {
      // Select plugins for supporting the still needed plain tags.
      $prospective_editor = clone $editor;
      $plugin_candidates_plain_tags = self::getCandidates($provided_plain_tags, $still_needed_plain_tags, $disabled_definitions, $prospective_editor);
      $selected_plugins_plain_tags = self::selectCandidate($plugin_candidates_plain_tags, $still_needed_plain_tags, array_keys($provided_plain_tags->getAllowedElements()));

      // Select plugins for supporting the still needed attributes.
      $prospective_editor_settings = $prospective_editor->getSettings();
      foreach (array_keys($selected_plugins_plain_tags) as $plugin_id) {
        $plugin_definition = $this->pluginManager->getDefinition($plugin_id);
        assert($plugin_definition instanceof CKEditor5PluginDefinition);
        if ($plugin_definition->hasToolbarItems()) {
          $prospective_editor_settings['toolbar']['items'] = [...$prospective_editor_settings['toolbar']['items'], ...array_keys($plugin_definition->getToolbarItems())];
        }
        if ($plugin_definition->isConfigurable()) {
          $prospective_editor_settings['plugins'][$plugin_id] = $this->pluginManager->createInstance($plugin_id)->defaultConfiguration();
        }
      }
      $prospective_editor->setSettings($prospective_editor_settings);
      $plugin_candidates_attributes = self::getCandidates($provided, $still_needed_attributes, array_diff_key($disabled_definitions, $selected_plugins_plain_tags), $prospective_editor);
      $selected_plugins_attributes = self::selectCandidate($plugin_candidates_attributes, $still_needed, array_keys($provided->getAllowedElements()));

      // Combine the selection.
      $selected_plugins = array_merge_recursive($selected_plugins_plain_tags, $selected_plugins_attributes);

      // If additional plugins need to be enabled to support attribute config,
      // loop through the list to enable the plugins and build a UI message that
      // will convey this plugin-enabling to the user.
      if (!empty($selected_plugins)) {
        $enabled_for_tags_message_content = '';
        $enabled_for_attributes_message_content = '';
        $editor_settings_to_update = $editor->getSettings();
        // Create new group for all the added toolbar items.
        $editor_settings_to_update['toolbar']['items'][] = '|';
        foreach ($selected_plugins as $plugin_id => $reason_why_enabled) {
          $plugin_definition = $this->pluginManager->getDefinition($plugin_id);
          $label = $plugin_definition->label();
          $plugins_enabled[] = $label;
          [$net_new] = self::computeNetNewElementsForPlugin($provided, $still_needed, $plugin_definition);
          // Track remaining elements/attributes that are still needed.
          $still_needed = $still_needed->diff($net_new);

          // Fulfill the purpose of this method: generate the settings to add
          // this plugin's toolbar item.
          if ($plugin_definition->hasToolbarItems()) {
            $editor_settings_to_update['toolbar']['items'] = array_merge($editor_settings_to_update['toolbar']['items'], array_keys($plugin_definition->getToolbarItems()));
            foreach ($reason_why_enabled as $attribute_name => $attribute_config) {
              // Plugin was selected for tag.
              if (in_array($attribute_name, ['-attributes-none-', '-attributes-any-'], TRUE)) {
                $tags = array_reduce(array_keys($net_new->getAllowedElements()), function ($carry, $item) {
                  return $carry . "<$item>";
                });
                $enabled_for_tags_message_content .= "$label (for tags: $tags) ";
                // This plugin does not add attributes: continue to next plugin.
                continue;
              }
              // Plugin was selected for attribute.
              $enabled_for_attributes_message_content .= "$label (";
              foreach ($attribute_config as $tag_name => $attribute_value_config) {
                $enabled_for_attributes_message_content .= " for tag: <$tag_name> to support: $attribute_name";
                if (is_array($attribute_value_config)) {
                  $enabled_for_attributes_message_content .= " with value(s): ";
                  foreach (array_keys($attribute_value_config) as $allowed_value) {
                    $enabled_for_attributes_message_content .= " $allowed_value,";
                  }
                  $enabled_for_attributes_message_content = substr($enabled_for_attributes_message_content, 0, -1) . '), ';
                }
              }
            }
          }
        }
        $editor->setSettings($editor_settings_to_update);
        // Some plugins enabled, maybe some missing tags or attributes.
        return [
          substr($enabled_for_tags_message_content, 0, -1),
          substr($enabled_for_attributes_message_content, 0, -2),
          $still_needed,
          $plugins_enabled,
        ];
      }
      else {
        // No plugins enabled, maybe some missing tags or attributes.
        return [
          NULL,
          NULL,
          $still_needed,
          NULL,
        ];
      }
    }
    else {
      return NULL;
    }
  }

  /**
   * Adds default settings for all enabled CKEditor 5 plugins.
   *
   * @param \Drupal\editor\EditorInterface $editor
   *   The text editor config entity to update.
   */
  private function addDefaultSettingsForEnabledConfigurablePlugins(EditorInterface $editor): void {
    $settings = $editor->getSettings();
    $update_settings = FALSE;
    $enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
    $configurable_definitions = array_filter($enabled_definitions, function (CKEditor5PluginDefinition $definition): bool {
      return $definition->isConfigurable();
    });

    foreach ($configurable_definitions as $plugin_name => $definition) {
      $default_plugin_configuration = $this->pluginManager->getPlugin($plugin_name, NULL)->defaultConfiguration();
      // Skip plugins with an empty default configuration, the plugin
      // configuration is most likely stored elsewhere. Also skip any plugin
      // that already has configuration data as default values are not needed.
      if ($default_plugin_configuration === [] || isset($settings['plugins'][$plugin_name])) {
        continue;
      }
      $update_settings = TRUE;
      $settings['plugins'][$plugin_name] = $default_plugin_configuration;
    }

    if ($update_settings) {
      $editor->setSettings($settings);
    }
  }

}
