/**
 * @file
 * Provides a JavaScript API to broadcast text editor configuration changes.
 *
 * Filter implementations may listen to the drupalEditorFeatureAdded,
 * drupalEditorFeatureRemoved, and drupalEditorFeatureRemoved events on document
 * to automatically adjust their settings based on the editor configuration.
 */

(function($, _, Drupal, document) {
  /**
   * Editor configuration namespace.
   *
   * @namespace
   */
  Drupal.editorConfiguration = {
    /**
     * Must be called by a specific text editor's configuration whenever a
     * feature is added by the user.
     *
     * Triggers the drupalEditorFeatureAdded event on the document, which
     * receives a {@link Drupal.EditorFeature} object.
     *
     * @param {Drupal.EditorFeature} feature
     *   A text editor feature object.
     *
     * @fires event:drupalEditorFeatureAdded
     */
    addedFeature(feature) {
      $(document).trigger('drupalEditorFeatureAdded', feature);
    },

    /**
     * Must be called by a specific text editor's configuration whenever a
     * feature is removed by the user.
     *
     * Triggers the drupalEditorFeatureRemoved event on the document, which
     * receives a {@link Drupal.EditorFeature} object.
     *
     * @param {Drupal.EditorFeature} feature
     *   A text editor feature object.
     *
     * @fires event:drupalEditorFeatureRemoved
     */
    removedFeature(feature) {
      $(document).trigger('drupalEditorFeatureRemoved', feature);
    },

    /**
     * Must be called by a specific text editor's configuration whenever a
     * feature is modified, i.e. has different rules.
     *
     * For example when the "Bold" button is configured to use the `<b>` tag
     * instead of the `<strong>` tag.
     *
     * Triggers the drupalEditorFeatureModified event on the document, which
     * receives a {@link Drupal.EditorFeature} object.
     *
     * @param {Drupal.EditorFeature} feature
     *   A text editor feature object.
     *
     * @fires event:drupalEditorFeatureModified
     */
    modifiedFeature(feature) {
      $(document).trigger('drupalEditorFeatureModified', feature);
    },

    /**
     * May be called by a specific text editor's configuration whenever a
     * feature is being added, to check whether it would require the filter
     * settings to be updated.
     *
     * The canonical use case is when a text editor is being enabled:
     * preferably
     * this would not cause the filter settings to be changed; rather, the
     * default set of buttons (features) for the text editor should adjust
     * itself to not cause filter setting changes.
     *
     * Note: for filters to integrate with this functionality, it is necessary
     * that they implement
     * `Drupal.filterSettingsForEditors[filterID].getRules()`.
     *
     * @param {Drupal.EditorFeature} feature
     *   A text editor feature object.
     *
     * @return {bool}
     *   Whether the given feature is allowed by the current filters.
     */
    featureIsAllowedByFilters(feature) {
      /**
       * Provided a section of a feature or filter rule, checks if no property
       * values are defined for all properties: attributes, classes and styles.
       *
       * @param {object} section
       *   The section to check.
       *
       * @return {bool}
       *   Returns true if the section has empty properties, false otherwise.
       */
      function emptyProperties(section) {
        return (
          section.attributes.length === 0 &&
          section.classes.length === 0 &&
          section.styles.length === 0
        );
      }

      /**
       * Generate the universe U of possible values that can result from the
       * feature's rules' requirements.
       *
       * This generates an object of this form:
       *   var universe = {
       *     a: {
       *       'touchedByAllowedPropertyRule': false,
       *       'tag': false,
       *       'attributes:href': false,
       *       'classes:external': false,
       *     },
       *     strong: {
       *       'touchedByAllowedPropertyRule': false,
       *       'tag': false,
       *     },
       *     img: {
       *       'touchedByAllowedPropertyRule': false,
       *       'tag': false,
       *       'attributes:src': false
       *     }
       *   };
       *
       * In this example, the given text editor feature resulted in the above
       * universe, which shows that it must be allowed to generate the a,
       * strong and img tags. For the a tag, it must be able to set the "href"
       * attribute and the "external" class. For the strong tag, no further
       * properties are required. For the img tag, the "src" attribute is
       * required. The "tag" key is used to track whether that tag was
       * explicitly allowed by one of the filter's rules. The
       * "touchedByAllowedPropertyRule" key is used for state tracking that is
       * essential for filterStatusAllowsFeature() to be able to reason: when
       * all of a filter's rules have been applied, and none of the forbidden
       * rules matched (which would have resulted in early termination) yet the
       * universe has not been made empty (which would be the end result if
       * everything in the universe were explicitly allowed), then this piece
       * of state data enables us to determine whether a tag whose properties
       * were not all explicitly allowed are in fact still allowed, because its
       * tag was explicitly allowed and there were no filter rules applying
       * "allowed tag property value" restrictions for this particular tag.
       *
       * @param {object} feature
       *   The feature in question.
       *
       * @return {object}
       *   The universe generated.
       *
       * @see findPropertyValueOnTag()
       * @see filterStatusAllowsFeature()
       */
      function generateUniverseFromFeatureRequirements(feature) {
        const properties = ['attributes', 'styles', 'classes'];
        const universe = {};

        for (let r = 0; r < feature.rules.length; r++) {
          const featureRule = feature.rules[r];

          // For each tag required by this feature rule, create a basic entry in
          // the universe.
          const requiredTags = featureRule.required.tags;
          for (let t = 0; t < requiredTags.length; t++) {
            universe[requiredTags[t]] = {
              // Whether this tag was allowed or not.
              tag: false,
              // Whether any filter rule that applies to this tag had an allowed
              // property rule. i.e. will become true if >=1 filter rule has >=1
              // allowed property rule.
              touchedByAllowedPropertyRule: false,
              // Analogous, but for forbidden property rule.
              touchedBytouchedByForbiddenPropertyRule: false,
            };
          }

          // If no required properties are defined for this rule, we can move on
          // to the next feature.
          if (emptyProperties(featureRule.required)) {
            continue;
          }

          // Expand the existing universe, assume that each tags' property
          // value is disallowed. If the filter rules allow everything in the
          // feature's universe, then the feature is allowed.
          for (let p = 0; p < properties.length; p++) {
            const property = properties[p];
            for (let pv = 0; pv < featureRule.required[property].length; pv++) {
              const propertyValue = featureRule.required[property];
              universe[requiredTags][`${property}:${propertyValue}`] = false;
            }
          }
        }

        return universe;
      }

      /**
       * Finds out if a specific property value (potentially containing
       * wildcards) exists on the given tag. When the "allowing" parameter
       * equals true, the universe will be updated if that specific property
       * value exists. Returns true if found, false otherwise.
       *
       * @param {object} universe
       *   The universe to check.
       * @param {string} tag
       *   The tag to look for.
       * @param {string} property
       *   The property to check.
       * @param {string} propertyValue
       *   The property value to check.
       * @param {bool} allowing
       *   Whether to update the universe or not.
       *
       * @return {bool}
       *   Returns true if found, false otherwise.
       */
      function findPropertyValueOnTag(
        universe,
        tag,
        property,
        propertyValue,
        allowing,
      ) {
        // If the tag does not exist in the universe, then it definitely can't
        // have this specific property value.
        if (!_.has(universe, tag)) {
          return false;
        }

        const key = `${property}:${propertyValue}`;

        // Track whether a tag was touched by a filter rule that allows specific
        // property values on this particular tag.
        // @see generateUniverseFromFeatureRequirements
        if (allowing) {
          universe[tag].touchedByAllowedPropertyRule = true;
        }

        // The simple case: no wildcard in property value.
        if (_.indexOf(propertyValue, '*') === -1) {
          if (_.has(universe, tag) && _.has(universe[tag], key)) {
            if (allowing) {
              universe[tag][key] = true;
            }
            return true;
          }
          return false;
        }
        // The complex case: wildcard in property value.

        let atLeastOneFound = false;
        const regex = key.replace(/\*/g, '[^ ]*');
        _.each(_.keys(universe[tag]), key => {
          if (key.match(regex)) {
            atLeastOneFound = true;
            if (allowing) {
              universe[tag][key] = true;
            }
          }
        });
        return atLeastOneFound;
      }

      /**
       * Calls findPropertyValuesOnAllTags for all tags in the universe.
       *
       * @param {object} universe
       *   The universe to check.
       * @param {string} property
       *   The property to check.
       * @param {Array} propertyValues
       *   Values of the property to check.
       * @param {bool} allowing
       *   Whether to update the universe or not.
       *
       * @return {bool}
       *   Returns true if found, false otherwise.
       */
      function findPropertyValuesOnAllTags(
        universe,
        property,
        propertyValues,
        allowing,
      ) {
        let atLeastOneFound = false;
        _.each(_.keys(universe), tag => {
          if (
            // eslint-disable-next-line no-use-before-define
            findPropertyValuesOnTag(
              universe,
              tag,
              property,
              propertyValues,
              allowing,
            )
          ) {
            atLeastOneFound = true;
          }
        });
        return atLeastOneFound;
      }

      /**
       * Calls findPropertyValueOnTag on the given tag for every property value
       * that is listed in the "propertyValues" parameter. Supports the wildcard
       * tag.
       *
       * @param {object} universe
       *   The universe to check.
       * @param {string} tag
       *   The tag to look for.
       * @param {string} property
       *   The property to check.
       * @param {Array} propertyValues
       *   Values of the property to check.
       * @param {bool} allowing
       *   Whether to update the universe or not.
       *
       * @return {bool}
       *   Returns true if found, false otherwise.
       */
      function findPropertyValuesOnTag(
        universe,
        tag,
        property,
        propertyValues,
        allowing,
      ) {
        // Detect the wildcard case.
        if (tag === '*') {
          return findPropertyValuesOnAllTags(
            universe,
            property,
            propertyValues,
            allowing,
          );
        }

        let atLeastOneFound = false;
        _.each(propertyValues, propertyValue => {
          if (
            findPropertyValueOnTag(
              universe,
              tag,
              property,
              propertyValue,
              allowing,
            )
          ) {
            atLeastOneFound = true;
          }
        });
        return atLeastOneFound;
      }

      /**
       * Calls deleteFromUniverseIfAllowed for all tags in the universe.
       *
       * @param {object} universe
       *   The universe to delete from.
       *
       * @return {bool}
       *   Whether something was deleted from the universe.
       */
      function deleteAllTagsFromUniverseIfAllowed(universe) {
        let atLeastOneDeleted = false;
        _.each(_.keys(universe), tag => {
          // eslint-disable-next-line no-use-before-define
          if (deleteFromUniverseIfAllowed(universe, tag)) {
            atLeastOneDeleted = true;
          }
        });
        return atLeastOneDeleted;
      }

      /**
       * Deletes a tag from the universe if the tag itself and each of its
       * properties are marked as allowed.
       *
       * @param {object} universe
       *   The universe to delete from.
       * @param {string} tag
       *   The tag to check.
       *
       * @return {bool}
       *   Whether something was deleted from the universe.
       */
      function deleteFromUniverseIfAllowed(universe, tag) {
        // Detect the wildcard case.
        if (tag === '*') {
          return deleteAllTagsFromUniverseIfAllowed(universe);
        }
        if (
          _.has(universe, tag) &&
          _.every(_.omit(universe[tag], 'touchedByAllowedPropertyRule'))
        ) {
          delete universe[tag];
          return true;
        }
        return false;
      }

      /**
       * Checks if any filter rule forbids either a tag or a tag property value
       * that exists in the universe.
       *
       * @param {object} universe
       *   Universe to check.
       * @param {object} filterStatus
       *   Filter status to use for check.
       *
       * @return {bool}
       *   Whether any filter rule forbids something in the universe.
       */
      function anyForbiddenFilterRuleMatches(universe, filterStatus) {
        const properties = ['attributes', 'styles', 'classes'];

        // Check if a tag in the universe is forbidden.
        const allRequiredTags = _.keys(universe);
        let filterRule;
        for (let i = 0; i < filterStatus.rules.length; i++) {
          filterRule = filterStatus.rules[i];
          if (filterRule.allow === false) {
            if (_.intersection(allRequiredTags, filterRule.tags).length > 0) {
              return true;
            }
          }
        }

        // Check if a property value of a tag in the universe is forbidden.
        // For all filter rules…
        for (let n = 0; n < filterStatus.rules.length; n++) {
          filterRule = filterStatus.rules[n];
          // … if there are tags with restricted property values …
          if (
            filterRule.restrictedTags.tags.length &&
            !emptyProperties(filterRule.restrictedTags.forbidden)
          ) {
            // … for all those tags …
            for (let j = 0; j < filterRule.restrictedTags.tags.length; j++) {
              const tag = filterRule.restrictedTags.tags[j];
              // … then iterate over all properties …
              for (let k = 0; k < properties.length; k++) {
                const property = properties[k];
                // … and return true if just one of the forbidden property
                // values for this tag and property is listed in the universe.
                if (
                  findPropertyValuesOnTag(
                    universe,
                    tag,
                    property,
                    filterRule.restrictedTags.forbidden[property],
                    false,
                  )
                ) {
                  return true;
                }
              }
            }
          }
        }

        return false;
      }

      /**
       * Applies every filter rule's explicit allowing of a tag or a tag
       * property value to the universe. Whenever both the tag and all of its
       * required property values are marked as explicitly allowed, they are
       * deleted from the universe.
       *
       * @param {object} universe
       *   Universe to delete from.
       * @param {object} filterStatus
       *   The filter status in question.
       */
      function markAllowedTagsAndPropertyValues(universe, filterStatus) {
        const properties = ['attributes', 'styles', 'classes'];

        // Check if a tag in the universe is allowed.
        let filterRule;
        let tag;
        for (
          let l = 0;
          !_.isEmpty(universe) && l < filterStatus.rules.length;
          l++
        ) {
          filterRule = filterStatus.rules[l];
          if (filterRule.allow === true) {
            for (
              let m = 0;
              !_.isEmpty(universe) && m < filterRule.tags.length;
              m++
            ) {
              tag = filterRule.tags[m];
              if (_.has(universe, tag)) {
                universe[tag].tag = true;
                deleteFromUniverseIfAllowed(universe, tag);
              }
            }
          }
        }

        // Check if a property value of a tag in the universe is allowed.
        // For all filter rules…
        for (
          let i = 0;
          !_.isEmpty(universe) && i < filterStatus.rules.length;
          i++
        ) {
          filterRule = filterStatus.rules[i];
          // … if there are tags with restricted property values …
          if (
            filterRule.restrictedTags.tags.length &&
            !emptyProperties(filterRule.restrictedTags.allowed)
          ) {
            // … for all those tags …
            for (
              let j = 0;
              !_.isEmpty(universe) && j < filterRule.restrictedTags.tags.length;
              j++
            ) {
              tag = filterRule.restrictedTags.tags[j];
              // … then iterate over all properties …
              for (let k = 0; k < properties.length; k++) {
                const property = properties[k];
                // … and try to delete this tag from the universe if just one
                // of the allowed property values for this tag and property is
                // listed in the universe. (Because everything might be allowed
                // now.)
                if (
                  findPropertyValuesOnTag(
                    universe,
                    tag,
                    property,
                    filterRule.restrictedTags.allowed[property],
                    true,
                  )
                ) {
                  deleteFromUniverseIfAllowed(universe, tag);
                }
              }
            }
          }
        }
      }

      /**
       * Checks whether the current status of a filter allows a specific feature
       * by building the universe of potential values from the feature's
       * requirements and then checking whether anything in the filter prevents
       * that.
       *
       * @param {object} filterStatus
       *   The filter status in question.
       * @param {object} feature
       *   The feature requested.
       *
       * @return {bool}
       *   Whether the current status of the filter allows specified feature.
       *
       * @see generateUniverseFromFeatureRequirements()
       */
      function filterStatusAllowsFeature(filterStatus, feature) {
        // An inactive filter by definition allows the feature.
        if (!filterStatus.active) {
          return true;
        }

        // A feature that specifies no rules has no HTML requirements and is
        // hence allowed by definition.
        if (feature.rules.length === 0) {
          return true;
        }

        // Analogously for a filter that specifies no rules.
        if (filterStatus.rules.length === 0) {
          return true;
        }

        // Generate the universe U of possible values that can result from the
        // feature's rules' requirements.
        const universe = generateUniverseFromFeatureRequirements(feature);

        // If anything that is in the universe (and is thus required by the
        // feature) is forbidden by any of the filter's rules, then this filter
        // does not allow this feature.
        if (anyForbiddenFilterRuleMatches(universe, filterStatus)) {
          return false;
        }

        // Mark anything in the universe that is allowed by any of the filter's
        // rules as allowed. If everything is explicitly allowed, then the
        // universe will become empty.
        markAllowedTagsAndPropertyValues(universe, filterStatus);

        // If there was at least one filter rule allowing tags, then everything
        // in the universe must be allowed for this feature to be allowed, and
        // thus by now it must be empty. However, it is still possible that the
        // filter allows the feature, due to no rules for allowing tag property
        // values and/or rules for forbidding tag property values. For details:
        // see the comments below.
        // @see generateUniverseFromFeatureRequirements()
        if (_.some(_.pluck(filterStatus.rules, 'allow'))) {
          // If the universe is empty, then everything was explicitly allowed
          // and our job is done: this filter allows this feature!
          if (_.isEmpty(universe)) {
            return true;
          }
          // Otherwise, it is still possible that this feature is allowed.

          // Every tag must be explicitly allowed if there are filter rules
          // doing tag whitelisting.
          if (!_.every(_.pluck(universe, 'tag'))) {
            return false;
          }
          // Every tag was explicitly allowed, but since the universe is not
          // empty, one or more tag properties are disallowed. However, if
          // only blacklisting of tag properties was applied to these tags,
          // and no whitelisting was ever applied, then it's still fine:
          // since none of the tag properties were blacklisted, we got to
          // this point, and since no whitelisting was applied, it doesn't
          // matter that the properties: this could never have happened
          // anyway. It's only this late that we can know this for certain.

          const tags = _.keys(universe);
          // Figure out if there was any rule applying whitelisting tag
          // restrictions to each of the remaining tags.
          for (let i = 0; i < tags.length; i++) {
            const tag = tags[i];
            if (_.has(universe, tag)) {
              if (universe[tag].touchedByAllowedPropertyRule === false) {
                delete universe[tag];
              }
            }
          }
          return _.isEmpty(universe);
        }
        // Otherwise, if all filter rules were doing blacklisting, then the sole
        // fact that we got to this point indicates that this filter allows for
        // everything that is required for this feature.

        return true;
      }

      // If any filter's current status forbids the editor feature, return
      // false.
      Drupal.filterConfiguration.update();
      return Object.keys(Drupal.filterConfiguration.statuses).every(filterID =>
        filterStatusAllowsFeature(
          Drupal.filterConfiguration.statuses[filterID],
          feature,
        ),
      );
    },
  };

  /**
   * Constructor for an editor feature HTML rule.
   *
   * Intended to be used in combination with {@link Drupal.EditorFeature}.
   *
   * A text editor feature rule object describes both:
   *  - required HTML tags, attributes, styles and classes: without these, the
   *    text editor feature is unable to function. It's possible that a
   *  - allowed HTML tags, attributes, styles and classes: these are optional
   *    in the strictest sense, but it is possible that the feature generates
   *    them.
   *
   * The structure can be very clearly seen below: there's a "required" and an
   * "allowed" key. For each of those, there are objects with the "tags",
   * "attributes", "styles" and "classes" keys. For all these keys the values
   * are initialized to the empty array. List each possible value as an array
   * value. Besides the "required" and "allowed" keys, there's an optional
   * "raw" key: it allows text editor implementations to optionally pass in
   * their raw representation instead of the Drupal-defined representation for
   * HTML rules.
   *
   * @example
   * tags: ['<a>']
   * attributes: ['href', 'alt']
   * styles: ['color', 'text-decoration']
   * classes: ['external', 'internal']
   *
   * @constructor
   *
   * @see Drupal.EditorFeature
   */
  Drupal.EditorFeatureHTMLRule = function() {
    /**
     *
     * @type {Object}
     *
     * @prop {Array} tags
     * @prop {Array} attributes
     * @prop {Array} styles
     * @prop {Array} classes
     */
    this.required = {
      tags: [],
      attributes: [],
      styles: [],
      classes: [],
    };

    /**
     *
     * @type {Object}
     *
     * @prop {Array} tags
     * @prop {Array} attributes
     * @prop {Array} styles
     * @prop {Array} classes
     */
    this.allowed = {
      tags: [],
      attributes: [],
      styles: [],
      classes: [],
    };

    /**
     *
     * @type {null}
     */
    this.raw = null;
  };

  /**
   * A text editor feature object. Initialized with the feature name.
   *
   * Contains a set of HTML rules ({@link Drupal.EditorFeatureHTMLRule} objects)
   * that describe which HTML tags, attributes, styles and classes are required
   * (i.e. essential for the feature to function at all) and which are allowed
   * (i.e. the feature may generate this, but they're not essential).
   *
   * It is necessary to allow for multiple HTML rules per feature: with just
   * one HTML rule per feature, there is not enough expressiveness to describe
   * certain cases. For example: a "table" feature would probably require the
   * `<table>` tag, and might allow e.g. the "summary" attribute on that tag.
   * However, the table feature would also require the `<tr>` and `<td>` tags,
   * but it doesn't make sense to allow for a "summary" attribute on these tags.
   * Hence these would need to be split in two separate rules.
   *
   * HTML rules must be added with the `addHTMLRule()` method. A feature that
   * has zero HTML rules does not create or modify HTML.
   *
   * @constructor
   *
   * @param {string} name
   *   The name of the feature.
   *
   * @see Drupal.EditorFeatureHTMLRule
   */
  Drupal.EditorFeature = function(name) {
    this.name = name;
    this.rules = [];
  };

  /**
   * Adds a HTML rule to the list of HTML rules for this feature.
   *
   * @param {Drupal.EditorFeatureHTMLRule} rule
   *   A text editor feature HTML rule.
   */
  Drupal.EditorFeature.prototype.addHTMLRule = function(rule) {
    this.rules.push(rule);
  };

  /**
   * Text filter status object. Initialized with the filter ID.
   *
   * Indicates whether the text filter is currently active (enabled) or not.
   *
   * Contains a set of HTML rules ({@link Drupal.FilterHTMLRule} objects) that
   * describe which HTML tags are allowed or forbidden. They can also describe
   * for a set of tags (or all tags) which attributes, styles and classes are
   * allowed and which are forbidden.
   *
   * It is necessary to allow for multiple HTML rules per feature, for
   * analogous reasons as {@link Drupal.EditorFeature}.
   *
   * HTML rules must be added with the `addHTMLRule()` method. A filter that has
   * zero HTML rules does not disallow any HTML.
   *
   * @constructor
   *
   * @param {string} name
   *   The name of the feature.
   *
   * @see Drupal.FilterHTMLRule
   */
  Drupal.FilterStatus = function(name) {
    /**
     *
     * @type {string}
     */
    this.name = name;

    /**
     *
     * @type {bool}
     */
    this.active = false;

    /**
     *
     * @type {Array.<Drupal.FilterHTMLRule>}
     */
    this.rules = [];
  };

  /**
   * Adds a HTML rule to the list of HTML rules for this filter.
   *
   * @param {Drupal.FilterHTMLRule} rule
   *   A text filter HTML rule.
   */
  Drupal.FilterStatus.prototype.addHTMLRule = function(rule) {
    this.rules.push(rule);
  };

  /**
   * A text filter HTML rule object.
   *
   * Intended to be used in combination with {@link Drupal.FilterStatus}.
   *
   * A text filter rule object describes:
   *  1. allowed or forbidden tags: (optional) whitelist or blacklist HTML tags
   *  2. restricted tag properties: (optional) whitelist or blacklist
   *     attributes, styles and classes on a set of HTML tags.
   *
   * Typically, each text filter rule object does either 1 or 2, not both.
   *
   * The structure can be very clearly seen below:
   *  1. use the "tags" key to list HTML tags, and set the "allow" key to
   *     either true (to allow these HTML tags) or false (to forbid these HTML
   *     tags). If you leave the "tags" key's default value (the empty array),
   *     no restrictions are applied.
   *  2. all nested within the "restrictedTags" key: use the "tags" subkey to
   *     list HTML tags to which you want to apply property restrictions, then
   *     use the "allowed" subkey to whitelist specific property values, and
   *     similarly use the "forbidden" subkey to blacklist specific property
   *     values.
   *
   * @example
   * <caption>Whitelist the "p", "strong" and "a" HTML tags.</caption>
   * {
   *   tags: ['p', 'strong', 'a'],
   *   allow: true,
   *   restrictedTags: {
   *     tags: [],
   *     allowed: { attributes: [], styles: [], classes: [] },
   *     forbidden: { attributes: [], styles: [], classes: [] }
   *   }
   * }
   * @example
   * <caption>For the "a" HTML tag, only allow the "href" attribute
   * and the "external" class and disallow the "target" attribute.</caption>
   * {
   *   tags: [],
   *   allow: null,
   *   restrictedTags: {
   *     tags: ['a'],
   *     allowed: { attributes: ['href'], styles: [], classes: ['external'] },
   *     forbidden: { attributes: ['target'], styles: [], classes: [] }
   *   }
   * }
   * @example
   * <caption>For all tags, allow the "data-*" attribute (that is, any
   * attribute that begins with "data-").</caption>
   * {
   *   tags: [],
   *   allow: null,
   *   restrictedTags: {
   *     tags: ['*'],
   *     allowed: { attributes: ['data-*'], styles: [], classes: [] },
   *     forbidden: { attributes: [], styles: [], classes: [] }
   *   }
   * }
   *
   * @return {object}
   *   An object with the following structure:
   * ```
   * {
   *   tags: Array,
   *   allow: null,
   *   restrictedTags: {
   *     tags: Array,
   *     allowed: {attributes: Array, styles: Array, classes: Array},
   *     forbidden: {attributes: Array, styles: Array, classes: Array}
   *   }
   * }
   * ```
   *
   * @see Drupal.FilterStatus
   */
  Drupal.FilterHTMLRule = function() {
    // Allow or forbid tags.
    this.tags = [];
    this.allow = null;

    // Apply restrictions to properties set on tags.
    this.restrictedTags = {
      tags: [],
      allowed: { attributes: [], styles: [], classes: [] },
      forbidden: { attributes: [], styles: [], classes: [] },
    };

    return this;
  };

  Drupal.FilterHTMLRule.prototype.clone = function() {
    const clone = new Drupal.FilterHTMLRule();
    clone.tags = this.tags.slice(0);
    clone.allow = this.allow;
    clone.restrictedTags.tags = this.restrictedTags.tags.slice(0);
    clone.restrictedTags.allowed.attributes = this.restrictedTags.allowed.attributes.slice(
      0,
    );
    clone.restrictedTags.allowed.styles = this.restrictedTags.allowed.styles.slice(
      0,
    );
    clone.restrictedTags.allowed.classes = this.restrictedTags.allowed.classes.slice(
      0,
    );
    clone.restrictedTags.forbidden.attributes = this.restrictedTags.forbidden.attributes.slice(
      0,
    );
    clone.restrictedTags.forbidden.styles = this.restrictedTags.forbidden.styles.slice(
      0,
    );
    clone.restrictedTags.forbidden.classes = this.restrictedTags.forbidden.classes.slice(
      0,
    );
    return clone;
  };

  /**
   * Tracks the configuration of all text filters in {@link Drupal.FilterStatus}
   * objects for {@link Drupal.editorConfiguration.featureIsAllowedByFilters}.
   *
   * @namespace
   */
  Drupal.filterConfiguration = {
    /**
     * Drupal.FilterStatus objects, keyed by filter ID.
     *
     * @type {Object.<string, Drupal.FilterStatus>}
     */
    statuses: {},

    /**
     * Live filter setting parsers.
     *
     * Object keyed by filter ID, for those filters that implement it.
     *
     * Filters should load the implementing JavaScript on the filter
     * configuration form and implement
     * `Drupal.filterSettings[filterID].getRules()`, which should return an
     * array of {@link Drupal.FilterHTMLRule} objects.
     *
     * @namespace
     */
    liveSettingParsers: {},

    /**
     * Updates all {@link Drupal.FilterStatus} objects to reflect current state.
     *
     * Automatically checks whether a filter is currently enabled or not. To
     * support more finegrained.
     *
     * If a filter implements a live setting parser, then that will be used to
     * keep the HTML rules for the {@link Drupal.FilterStatus} object
     * up-to-date.
     */
    update() {
      Object.keys(Drupal.filterConfiguration.statuses || {}).forEach(
        filterID => {
          // Update status.
          Drupal.filterConfiguration.statuses[filterID].active = $(
            `[name="filters[${filterID}][status]"]`,
          ).is(':checked');

          // Update current rules.
          if (Drupal.filterConfiguration.liveSettingParsers[filterID]) {
            Drupal.filterConfiguration.statuses[
              filterID
            ].rules = Drupal.filterConfiguration.liveSettingParsers[
              filterID
            ].getRules();
          }
        },
      );
    },
  };

  /**
   * Initializes {@link Drupal.filterConfiguration}.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Gets filter configuration from filter form input.
   */
  Drupal.behaviors.initializeFilterConfiguration = {
    attach(context, settings) {
      const $context = $(context);

      $context
        .find('#filters-status-wrapper input.form-checkbox')
        .once('filter-editor-status')
        .each(function() {
          const $checkbox = $(this);
          const nameAttribute = $checkbox.attr('name');

          // The filter's checkbox has a name attribute of the form
          // "filters[<name of filter>][status]", parse "<name of filter>"
          // from it.
          const filterID = nameAttribute.substring(
            8,
            nameAttribute.indexOf(']'),
          );

          // Create a Drupal.FilterStatus object to track the state (whether it's
          // active or not and its current settings, if any) of each filter.
          Drupal.filterConfiguration.statuses[
            filterID
          ] = new Drupal.FilterStatus(filterID);
        });
    },
  };
})(jQuery, _, Drupal, document);
