/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const {
  l10n,
  l10nFormatStr,
} = require("resource://devtools/shared/inspector/css-logic.js");
const {
  InplaceEditor,
  editableField,
} = require("resource://devtools/client/shared/inplace-editor.js");
const {
  createChild,
  appendText,
  advanceValidate,
  blurOnMultipleProperties,
} = require("resource://devtools/client/inspector/shared/utils.js");
const { throttle } = require("resource://devtools/shared/throttle.js");
const {
  style: { ELEMENT_STYLE },
} = require("resource://devtools/shared/constants.js");
const {
  canPointerEventDrag,
} = require("resource://devtools/client/shared/events.js");

loader.lazyRequireGetter(
  this,
  ["parseDeclarations", "parseSingleValue"],
  "resource://devtools/shared/css/parsing-utils.js",
  true
);
loader.lazyGetter(this, "PROPERTY_NAME_INPUT_LABEL", function () {
  return l10n("rule.propertyName.label");
});
loader.lazyGetter(this, "SHORTHAND_EXPANDER_TOOLTIP", function () {
  return l10n("rule.shorthandExpander.tooltip");
});

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
});

const HTML_NS = "http://www.w3.org/1999/xhtml";

const SHARED_SWATCH_CLASS = "inspector-swatch";
const COLOR_SWATCH_CLASS = "inspector-colorswatch";
const BEZIER_SWATCH_CLASS = "inspector-bezierswatch";
const LINEAR_EASING_SWATCH_CLASS = "inspector-lineareasingswatch";
const FILTER_SWATCH_CLASS = "inspector-filterswatch";
const ANGLE_SWATCH_CLASS = "inspector-angleswatch";
const FONT_FAMILY_CLASS = "ruleview-font-family";
const SHAPE_SWATCH_CLASS = "inspector-shapeswatch";

/*
 * An actionable element is an element which on click triggers a specific action
 * (e.g. shows a color tooltip, opens a link, …).
 */
const ACTIONABLE_ELEMENTS_SELECTORS = [
  `.${COLOR_SWATCH_CLASS}`,
  `.${BEZIER_SWATCH_CLASS}`,
  `.${LINEAR_EASING_SWATCH_CLASS}`,
  `.${FILTER_SWATCH_CLASS}`,
  `.${ANGLE_SWATCH_CLASS}`,
  "a",
];

/*
 * Speeds at which we update the value when the user is dragging its mouse
 * over a value.
 */
const SLOW_DRAGGING_SPEED = 0.1;
const DEFAULT_DRAGGING_SPEED = 1;
const FAST_DRAGGING_SPEED = 10;

// Deadzone in pixels where dragging should not update the value.
const DRAGGING_DEADZONE_DISTANCE = 5;

const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable";
const IS_DRAGGING_CLASSNAME = "ruleview-propertyvalue-dragging";

/**
 * TextPropertyEditor is responsible for the following:
 *   Owns a TextProperty object.
 *   Manages changes to the TextProperty.
 *   Can be expanded to display computed properties.
 *   Can mark a property disabled or enabled.
 *
 * @param {RuleEditor} ruleEditor
 *        The rule editor that owns this TextPropertyEditor.
 * @param {TextProperty} property
 *        The text property to edit.
 * @param {object} options
 * @param {Set} options.elementsWithPendingClicks
 */
class TextPropertyEditor {
  constructor(ruleEditor, property, options) {
    this.ruleEditor = ruleEditor;
    this.ruleView = this.ruleEditor.ruleView;
    this.cssProperties = this.ruleView.cssProperties;
    this.doc = this.ruleEditor.doc;
    this.popup = this.ruleView.popup;
    this.prop = property;
    this.prop.editor = this;
    this.browserWindow = this.doc.defaultView.top;
    this.#elementsWithPendingClicks = options.elementsWithPendingClicks;

    this.toolbox = this.ruleView.inspector.toolbox;
    this.telemetry = this.toolbox.telemetry;

    this.#onValidate = this.ruleView.debounce(this.#previewValue, 10, this);

    this.#createUI();
    this.update();
  }

  #populatedComputed = false;
  #hasPendingClick = false;
  #clickedElementOptions = null;
  #populatedShorthandOverridden;
  #elementsWithPendingClicks;

  #colorSwatchSpans;
  #bezierSwatchSpans;
  #linearEasingSwatchSpans;
  #filterSwatchSpan;

  #onValidate;
  #isDragging = false;
  #capturingPointerId = null;
  #hasDragged = false;
  #draggingController = null;
  #draggingValueCache = null;

  // Property value when we start editing or opening a swatch
  // (used to revert back to the value before edition)
  #initialValue = null;

  /**
   * Boolean indicating if the name or value is being currently edited.
   */
  get editing() {
    return !!(this.nameSpan.inplaceEditor || this.valueSpan.inplaceEditor);
  }

  /**
   * Get the Rule model to the current text property
   */
  get rule() {
    return this.prop.rule;
  }

  // Exposed for tests.
  get _DRAGGING_DEADZONE_DISTANCE() {
    return DRAGGING_DEADZONE_DISTANCE;
  }

  /**
   * Create the property editor's DOM.
   */
  #createUI() {
    const win = this.doc.defaultView;
    this.abortController = new win.AbortController();

    this.element = this.doc.createElementNS(HTML_NS, "div");
    this.element.setAttribute("role", "listitem");
    this.element.classList.add("ruleview-property");
    this.element.dataset.declarationId = this.prop.id;
    this.element._textPropertyEditor = this;

    this.container = createChild(this.element, "div", {
      class: "ruleview-propertycontainer",
    });

    const indent = ((this.rule.domRule.ancestorData.length || 0) + 1) * 2;
    createChild(this.container, "span", {
      class: "ruleview-rule-indent clipboard-only",
      textContent: " ".repeat(indent),
    });

    // The enable checkbox will disable or enable the rule.
    this.enable = createChild(this.container, "input", {
      type: "checkbox",
      class: "ruleview-enableproperty",
      title: l10nFormatStr("rule.propertyToggle.label", this.prop.name),
    });

    this.nameContainer = createChild(this.container, "span", {
      class: "ruleview-namecontainer",
    });

    // Property name, editable when focused.  Property name
    // is committed when the editor is unfocused.
    this.nameSpan = createChild(this.nameContainer, "span", {
      class: "ruleview-propertyname theme-fg-color3",
      tabindex: this.ruleEditor.isEditable ? "0" : "-1",
      id: this.prop.id,
    });

    appendText(this.nameContainer, ": ");

    // Create a span that will hold the property and semicolon.
    // Use this span to create a slightly larger click target
    // for the value.
    this.valueContainer = createChild(this.container, "span", {
      class: "ruleview-propertyvaluecontainer",
    });

    // Property value, editable when focused.  Changes to the
    // property value are applied as they are typed, and reverted
    // if the user presses escape.
    this.valueSpan = createChild(this.valueContainer, "span", {
      class: "ruleview-propertyvalue theme-fg-color1",
      tabindex: this.ruleEditor.isEditable ? "0" : "-1",
    });

    // Storing the TextProperty on the elements for easy access
    // (for instance by the tooltip)
    this.valueSpan.textProperty = this.prop;
    this.nameSpan.textProperty = this.prop;

    appendText(this.valueContainer, ";");

    // This needs to be called after valueContainer, nameSpan and valueSpan are created.
    if (this.#shouldShowComputedExpander) {
      this.#createComputedExpander();
    }

    if (this.#shouldShowWarning) {
      this.#createWarningIcon();
    }

    if (this.#isInvalidAtComputedValueTime()) {
      this.#createInvalidAtComputedValueTimeIcon();
    }

    if (this.#shouldShowInactiveCssState) {
      this.#createInactiveCssWarningIcon();
    }

    if (this.#shouldShowFilterProperty) {
      this.#createFilterPropertyButton();
    }

    // Only bind event handlers if the rule is editable.
    if (this.ruleEditor.isEditable) {
      this.enable.addEventListener("click", this.#onEnableClicked, {
        signal: this.abortController.signal,
        capture: true,
      });
      this.enable.addEventListener("change", this.#onEnableChanged, {
        signal: this.abortController.signal,
        capture: true,
      });

      this.nameContainer.addEventListener(
        "click",
        event => {
          // Clicks within the name shouldn't propagate any further.
          event.stopPropagation();

          // Forward clicks on nameContainer to the editable nameSpan
          if (event.target === this.nameContainer) {
            this.nameSpan.click();
          }
        },
        { signal: this.abortController.signal }
      );

      const getCssVariables = () =>
        this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement);

      editableField({
        start: this.#onStartEditing,
        element: this.nameSpan,
        done: this.#onNameDone,
        destroy: this.updateUI,
        advanceChars: ":",
        contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
        popup: this.popup,
        cssProperties: this.cssProperties,
        getCssVariables,
        // (Shift+)Tab will move the focus to the previous/next editable field (so property value
        // or new selector).
        focusEditableFieldAfterApply: true,
        focusEditableFieldContainerSelector: ".ruleview-rule",
        // We don't want Enter to trigger the next editable field, just to validate
        // what the user entered, close the editor, and focus the span so the user can
        // navigate with the keyboard as expected, unless the user has
        // devtools.inspector.rule-view.focusNextOnEnter set to true
        stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true,
        inputAriaLabel: PROPERTY_NAME_INPUT_LABEL,
      });

      // Auto blur name field on multiple CSS rules get pasted in.
      this.nameContainer.addEventListener(
        "paste",
        blurOnMultipleProperties(this.cssProperties),
        { signal: this.abortController.signal }
      );

      this.valueContainer.addEventListener(
        "click",
        async event => {
          // Clicks within the value shouldn't propagate any further.
          event.stopPropagation();

          // Forward clicks on valueContainer to the editable valueSpan
          if (event.target === this.valueContainer) {
            this.valueSpan.click();
          }

          if (event.target.classList.contains("ruleview-variable-link")) {
            const isRuleInStartingStyle = this.rule.isInStartingStyle();
            const rulePseudoElement = this.rule.pseudoElement;
            await this.ruleView.highlightProperty(
              event.target.dataset.variableName,
              {
                ruleValidator: rule => {
                  // If the associated rule is not in starting style, the variable
                  // definition can't be in a starting style rule.
                  // Note that if the rule is in starting style, then the variable
                  // definition might be in a starting style rule, or in a regular one.
                  if (!isRuleInStartingStyle && rule.isInStartingStyle()) {
                    return false;
                  }

                  if (
                    rule.pseudoElement &&
                    rulePseudoElement !== rule.pseudoElement
                  ) {
                    return false;
                  }

                  return true;
                },
              }
            );
          }
        },
        { signal: this.abortController.signal }
      );

      // The mousedown event could trigger a blur event on nameContainer, which
      // will trigger a call to the update function. The update function clears
      // valueSpan's markup. Thus the regular click event does not bubble up, and
      // listener's callbacks are not called.
      // So we need to remember where the user clicks in order to re-trigger the click
      // after the valueSpan's markup is re-populated. We only need to track this for
      // valueSpan's child elements, because direct click on valueSpan will always
      // trigger a click event.
      this.valueSpan.addEventListener(
        "mousedown",
        event => {
          const clickedEl = event.target;
          if (clickedEl === this.valueSpan) {
            return;
          }
          this.#hasPendingClick = true;
          this.#elementsWithPendingClicks.add(this.valueSpan);

          const matchedSelector = ACTIONABLE_ELEMENTS_SELECTORS.find(selector =>
            clickedEl.matches(selector)
          );
          if (matchedSelector) {
            const similarElements = [
              ...this.valueSpan.querySelectorAll(matchedSelector),
            ];
            this.#clickedElementOptions = {
              selector: matchedSelector,
              index: similarElements.indexOf(clickedEl),
            };
          }
        },
        { signal: this.abortController.signal }
      );

      this.valueSpan.addEventListener(
        "pointerup",
        () => {
          // if we have dragged, we will handle the pending click in #draggingOnPointerUp instead
          if (this.#hasDragged) {
            return;
          }
          this.#clickedElementOptions = null;
          this.#hasPendingClick = false;
          this.#elementsWithPendingClicks.delete(this.valueSpan);
        },
        { signal: this.abortController.signal }
      );

      this.ruleView.on(
        "draggable-preference-updated",
        this.#onDraggablePreferenceChanged,
        { signal: this.abortController.signal }
      );
      if (this.#isDraggableProperty(this.prop)) {
        this.#addDraggingCapability();
      }

      editableField({
        start: this.#onStartEditing,
        element: this.valueSpan,
        done: this.#onValueDone,
        destroy: async onValueDonePromise => {
          const cb = this.update;
          // The `done` callback is called before this `destroy` callback is.
          // In #onValueDone, we might preview/set the property and we want to wait for
          // that to be resolved before updating the view so all data are up to date (see Bug 1325145).
          //
          // Note that it is important to only await if a promise is passed,
          // otherwise browser_rules_grid-template-areas.js starts failing because of a race condition.
          if (typeof onValueDonePromise?.then == "function") {
            await onValueDonePromise;
          }
          return cb();
        },
        validate: this.#onValidate,
        advanceChars: advanceValidate,
        contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE,
        property: this.prop,
        defaultIncrement: this.prop.name === "opacity" ? 0.1 : 1,
        popup: this.popup,
        multiline: true,
        maxWidth: () => this.container.getBoundingClientRect().width,
        cssProperties: this.cssProperties,
        getCssAnchors: this.#getAnchorNames,
        getCssVariables,
        getGridLineNames: this.#getGridlineNames,
        showSuggestCompletionOnEmpty: true,
        // (Shift+)Tab will move the focus to the previous/next editable field (so property name,
        // or new property).
        focusEditableFieldAfterApply: true,
        focusEditableFieldContainerSelector: ".ruleview-rule",
        // We don't want Enter to trigger the next editable field, just to validate
        // what the user entered, close the editor, and focus the span so the user can
        // navigate with the keyboard as expected, unless the user has
        // devtools.inspector.rule-view.focusNextOnEnter set to true
        stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true,
        // Label the value input with the name span so screenreader users know what this
        // applies to.
        inputAriaLabelledBy: this.nameSpan.id,
      });
    }
  }

  /**
   * Get the names of the anchors the currently selected element can be anchored to
   *
   * @return {Array<string>}
   */
  #getAnchorNames = async () => {
    const names = await this.ruleView.pageStyle.getAnchorNames(
      this.ruleView.inspector.selection.nodeFront
    );

    // Emit message for test files
    this.ruleView.inspector.emitForTests("anchor-names-updated");
    return names;
  };

  /**
   * Get the grid line names of the grid that the currently selected element is
   * contained in.
   *
   * @return {object} Contains the names of the cols and rows as arrays
   * {cols: [], rows: []}.
   */
  #getGridlineNames = async () => {
    const gridLineNames = { cols: [], rows: [] };
    const layoutInspector =
      await this.ruleView.inspector.walker.getLayoutInspector();
    const gridFront = await layoutInspector.getCurrentGrid(
      this.ruleView.inspector.selection.nodeFront
    );

    if (gridFront) {
      const gridFragments = gridFront.gridFragments;

      for (const gridFragment of gridFragments) {
        for (const rowLine of gridFragment.rows.lines) {
          // We specifically ignore implicit line names created from implicitly named
          // areas. This is because showing implicit line names can be confusing for
          // designers who may have used a line name with "-start" or "-end" and created
          // an implicitly named grid area without meaning to.
          let gridArea;

          for (const name of rowLine.names) {
            const rowLineName =
              name.substring(0, name.lastIndexOf("-start")) ||
              name.substring(0, name.lastIndexOf("-end"));
            gridArea = gridFragment.areas.find(
              area => area.name === rowLineName
            );

            if (
              rowLine.type === "implicit" &&
              gridArea &&
              gridArea.type === "implicit"
            ) {
              continue;
            }
            gridLineNames.rows.push(name);
          }
        }

        for (const colLine of gridFragment.cols.lines) {
          let gridArea;

          for (const name of colLine.names) {
            const colLineName =
              name.substring(0, name.lastIndexOf("-start")) ||
              name.substring(0, name.lastIndexOf("-end"));
            gridArea = gridFragment.areas.find(
              area => area.name === colLineName
            );

            if (
              colLine.type === "implicit" &&
              gridArea &&
              gridArea.type === "implicit"
            ) {
              continue;
            }
            gridLineNames.cols.push(name);
          }
        }
      }
    }

    // Emit message for test files
    this.ruleView.inspector.emit("grid-line-names-updated");
    return gridLineNames;
  };

  /**
   * Get the path from which to resolve requests for this
   * rule's stylesheet.
   *
   * @return {string} the stylesheet's href.
   */
  get #sheetHref() {
    const domRule = this.rule.domRule;
    if (domRule) {
      return domRule.href || domRule.nodeHref;
    }
    return undefined;
  }

  /**
   * Populate the span based on changes to the TextProperty.
   */
  // eslint-disable-next-line complexity
  update = () => {
    if (this.ruleView.isDestroyed) {
      return;
    }

    this.updateUI();

    const name = this.prop.name;
    this.nameSpan.textContent = name;
    this.enable.setAttribute(
      "title",
      l10nFormatStr("rule.propertyToggle.label", name)
    );

    // Combine the property's value and priority into one string for
    // the value.
    const store = this.rule.elementStyle.store;
    let val = store.userProperties.getProperty(
      this.rule.domRule,
      name,
      this.prop.value
    );
    if (this.prop.priority) {
      val += " !" + this.prop.priority;
    }

    const propDirty = this.prop.isPropertyChanged;

    if (propDirty) {
      this.element.setAttribute("dirty", "");
    } else {
      this.element.removeAttribute("dirty");
    }

    const { outputParser } = this.ruleView;
    this.outputParserOptions = {
      angleClass: "ruleview-angle",
      angleSwatchClass: SHARED_SWATCH_CLASS + " " + ANGLE_SWATCH_CLASS,
      bezierClass: "ruleview-bezier",
      bezierSwatchClass: SHARED_SWATCH_CLASS + " " + BEZIER_SWATCH_CLASS,
      colorClass: "ruleview-color",
      colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS,
      filterClass: "ruleview-filter",
      filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS,
      flexClass: "inspector-flex js-toggle-flexbox-highlighter",
      gridClass: "inspector-grid js-toggle-grid-highlighter",
      linearEasingClass: "ruleview-lineareasing",
      linearEasingSwatchClass:
        SHARED_SWATCH_CLASS + " " + LINEAR_EASING_SWATCH_CLASS,
      shapeClass: "inspector-shape",
      shapeSwatchClass: SHAPE_SWATCH_CLASS,
      // Only ask the parser to convert colors to the default color type specified by the
      // user if the property hasn't been changed yet.
      useDefaultColorUnit: !propDirty,
      defaultColorUnit: this.ruleView.inspector.defaultColorUnit,
      urlClass: "theme-link",
      fontFamilyClass: FONT_FAMILY_CLASS,
      baseURI: this.#sheetHref,
      unmatchedClass: "inspector-unmatched",
      matchedVariableClass: "inspector-variable",
      getVariableData: varName =>
        this.rule.elementStyle.getVariableData(
          varName,
          this.rule.pseudoElement
        ),
      getAttributeValue: attrName => {
        const nodeFront = this.rule.elementStyle.element.isPseudoElement
          ? // get the closest non pseudo element
            this.rule.elementStyle.element.getUltimateOriginatingElement()
          : this.rule.elementStyle.element;

        const attribute = nodeFront.attributes.find(
          attr => attr.name === attrName
        );
        if (!attribute) {
          return null;
        }

        return attribute.value;
      },
      inStartingStyleRule: this.rule.isInStartingStyle(),
      isValid: this.isValid(),
      cssExplainersEnabled: this.ruleView.cssExplainersEnabled,
      siblingCount: this.rule.siblingCount,
      siblingIndex: this.rule.siblingIndex,
    };

    if (this.rule.darkColorScheme !== undefined) {
      this.outputParserOptions.isDarkColorScheme = this.rule.darkColorScheme;
    }
    const frag = outputParser.parseCssProperty(
      name,
      val,
      this.outputParserOptions
    );

    // Save the initial value as the last committed value,
    // for restoring after pressing escape.
    if (!this.committed) {
      this.committed = {
        name,
        value: frag.textContent,
        priority: this.prop.priority,
      };
    }

    this.valueSpan.innerHTML = "";
    this.valueSpan.appendChild(frag);
    if (
      this.valueSpan.textProperty?.name === "grid-template-areas" &&
      (this.valueSpan.innerText.includes(`"`) ||
        this.valueSpan.innerText.includes(`'`))
    ) {
      this.#formatGridTemplateAreasValue();
    }

    this.ruleView.emit("property-value-updated", {
      rule: this.rule,
      property: name,
      value: val,
    });

    // Highlight the currently used font in font-family properties.
    // If we cannot find a match, highlight the first generic family instead.
    const fontFamilySpans = this.valueSpan.querySelectorAll(
      "." + FONT_FAMILY_CLASS
    );
    if (fontFamilySpans.length && this.prop.enabled && !this.prop.overridden) {
      // This code branch was historically spawn in a distinct async task
      // but it may not be strictly required.
      (async () => {
        try {
          const families = await this.rule.elementStyle.getUsedFontFamilies();
          for (const span of fontFamilySpans) {
            const authoredFont = span.textContent.toLowerCase();
            if (families.has(authoredFont)) {
              span.classList.add("used-font");
              // In case a font-family appears multiple time in the value, we only want
              // to highlight the first occurence.
              families.delete(authoredFont);
            }
          }

          this.ruleView.emit("font-highlighted", this.valueSpan);
        } catch (e) {
          console.error("Could not get the list of font families", e);
        }
      })();
    }

    if (this.ruleEditor.isEditable) {
      this.#addSwatches();
    }

    const nodeFront = this.ruleView.inspector.selection.nodeFront;

    const flexToggle = this.valueSpan.querySelector(".inspector-flex");
    if (flexToggle) {
      flexToggle.setAttribute("title", l10n("rule.flexToggle.tooltip"));
      flexToggle.setAttribute(
        "aria-pressed",
        this.ruleView.inspector.highlighters.getNodeForActiveHighlighter(
          this.ruleView.inspector.highlighters.TYPES.FLEXBOX
        ) === nodeFront
      );
    }

    const gridToggle = this.valueSpan.querySelector(".inspector-grid");
    if (gridToggle) {
      gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip"));
      gridToggle.setAttribute(
        "aria-pressed",
        this.ruleView.highlighters.gridHighlighters.has(nodeFront)
      );
      gridToggle.toggleAttribute(
        "disabled",
        !this.ruleView.highlighters.canGridHighlighterToggle(nodeFront)
      );
    }

    const shapeToggle = this.valueSpan.querySelector(".inspector-shapeswatch");
    if (shapeToggle) {
      const mode =
        "css" +
        name
          .split("-")
          .map(s => {
            return s[0].toUpperCase() + s.slice(1);
          })
          .join("");
      shapeToggle.setAttribute("data-mode", mode);
      shapeToggle.setAttribute("aria-pressed", false);
      shapeToggle.setAttribute("title", l10n("rule.shapeToggle.tooltip"));
    }

    // Now that we have updated the property's value, we might have a pending
    // click on the value container. If we do, we have to trigger a click event
    // on the right element.
    // If we are dragging, we don't need to handle the pending click
    if (this.#hasPendingClick && !this.#isDragging) {
      this.#hasPendingClick = false;
      this.#elementsWithPendingClicks.delete(this.valueSpan);
      let elToClick;

      if (this.#clickedElementOptions !== null) {
        const { selector, index } = this.#clickedElementOptions;
        elToClick = this.valueSpan.querySelectorAll(selector)[index];

        this.#clickedElementOptions = null;
      }

      if (!elToClick) {
        elToClick = this.valueSpan;
      }
      elToClick.click();
    }

    // Populate the computed styles and shorthand overridden styles.
    this.#updateComputed();
    this.#updateShorthandOverridden();

    // Update the rule property highlight.
    this.ruleView.updatePropertyHighlight(this);
  };

  /**
   * Register all "swatches". i.e. all rounded icons in declaration values
   * from which you can open tooltips to configure complex values easily.
   * Like: colors, filters,...
   *
   * Note that the DOM Elements for the rounded icons are generated by
   * the OutputParser. This code only register event listeners on them
   * to show/hide the tooltip and connect it with the rule view.
   */
  #addSwatches() {
    const { tooltips } = this.ruleView;
    // Attach the color picker tooltip to the color swatches
    const previousColorSwatchSpans = this.#colorSwatchSpans;
    this.#colorSwatchSpans = this.valueSpan.querySelectorAll(
      "." + COLOR_SWATCH_CLASS
    );
    const colorSwatchTitle = l10n("rule.colorSwatch.tooltip");
    const colorTooltip = tooltips.getTooltip("colorPicker");
    for (let i = 0; i < this.#colorSwatchSpans.length; i++) {
      const span = this.#colorSwatchSpans[i];
      colorTooltip.addSwatch(
        span,
        {
          onShow: this.#onStartEditing,
          onPreview: this.#onSwatchPreview,
          onCommit: this.#onSwatchCommit,
          onRevert: this.#onSwatchRevert,
        },
        previousColorSwatchSpans ? previousColorSwatchSpans[i] : null
      );
      span.setAttribute("title", colorSwatchTitle);
      span.dataset.propertyName = this.nameSpan.textContent;
    }

    // Attach the cubic-bezier tooltip to the bezier swatches
    const previousBezierSwatchSpans = this.#bezierSwatchSpans;
    this.#bezierSwatchSpans = this.valueSpan.querySelectorAll(
      "." + BEZIER_SWATCH_CLASS
    );
    const bezierSwatchTitle = l10n("rule.bezierSwatch.tooltip");
    const cubicTooltip = tooltips.getTooltip("cubicBezier");
    for (let i = 0; i < this.#bezierSwatchSpans.length; i++) {
      const span = this.#bezierSwatchSpans[i];
      cubicTooltip.addSwatch(
        span,
        {
          onShow: this.#onStartEditing,
          onPreview: this.#onSwatchPreview,
          onCommit: this.#onSwatchCommit,
          onRevert: this.#onSwatchRevert,
        },
        previousBezierSwatchSpans ? previousBezierSwatchSpans[i] : null
      );
      span.setAttribute("title", bezierSwatchTitle);
    }

    // Attach the linear easing tooltip to the linear easing swatches
    const previousLinearEasingSwatchSpans = this.#linearEasingSwatchSpans;
    this.#linearEasingSwatchSpans = this.valueSpan.querySelectorAll(
      "." + LINEAR_EASING_SWATCH_CLASS
    );
    const linearTooltip = tooltips.getTooltip("linearEaseFunction");
    for (let i = 0; i < this.#linearEasingSwatchSpans.length; i++) {
      const span = this.#linearEasingSwatchSpans[i];
      linearTooltip.addSwatch(
        span,
        {
          onShow: this.#onStartEditing,
          onPreview: this.#onSwatchPreview,
          onCommit: this.#onSwatchCommit,
          onRevert: this.#onSwatchRevert,
        },
        previousLinearEasingSwatchSpans
          ? previousLinearEasingSwatchSpans[i]
          : null
      );
      span.setAttribute("title", bezierSwatchTitle);
    }

    // Attach the filter editor tooltip to the filter swatch
    const previousFilterSwatchSpan = this.#filterSwatchSpan;
    this.#filterSwatchSpan = this.valueSpan.querySelector(
      "." + FILTER_SWATCH_CLASS
    );
    if (this.#filterSwatchSpan) {
      tooltips.getTooltip("filterEditor").addSwatch(
        this.#filterSwatchSpan,
        {
          onShow: this.#onStartEditing,
          onPreview: this.#onSwatchPreview,
          onCommit: this.#onSwatchCommit,
          onRevert: this.#onSwatchRevert,
        },
        this.ruleView.outputParser,
        { ...this.outputParserOptions, filterSwatch: true },
        previousFilterSwatchSpan
      );
      const title = l10n("rule.filterSwatch.tooltip");
      this.#filterSwatchSpan.setAttribute("title", title);
    }

    this.angleSwatchSpans = this.valueSpan.querySelectorAll(
      "." + ANGLE_SWATCH_CLASS
    );
    const angleSwatchTitle = l10n("rule.angleSwatch.tooltip");
    for (const angleSpan of this.angleSwatchSpans) {
      angleSpan.addEventListener("unit-change", this.#onSwatchCommit);
      angleSpan.setAttribute("title", angleSwatchTitle);
    }
  }

  /**
   * Unregister swatches registered via #addSwatches
   */
  #removeSwatches() {
    const { tooltips } = this.ruleView;
    if (this.#colorSwatchSpans?.length) {
      const tooltip = tooltips.getTooltip("colorPicker");
      for (const span of this.#colorSwatchSpans) {
        tooltip.removeSwatch(span);
      }
    }

    if (this.angleSwatchSpans?.length) {
      for (const span of this.angleSwatchSpans) {
        span.removeEventListener("unit-change", this.#onSwatchCommit);
      }
    }

    if (this.#bezierSwatchSpans?.length) {
      const tooltip = tooltips.getTooltip("cubicBezier");
      for (const span of this.#bezierSwatchSpans) {
        tooltip.removeSwatch(span);
      }
    }

    if (this.#linearEasingSwatchSpans?.length) {
      const tooltip = tooltips.getTooltip("linearEaseFunction");
      for (const span of this.#linearEasingSwatchSpans) {
        tooltip.removeSwatch(span);
      }
    }

    if (this.#filterSwatchSpan) {
      tooltips.getTooltip("filterEditor").removeSwatch(this.#filterSwatchSpan);
    }
  }

  #onStartEditing = () => {
    this.element.classList.remove("ruleview-overridden", "ruleview-invalid");
    this.enable.style.visibility = "hidden";
    if (this.filterProperty) {
      this.filterProperty.hidden = true;
    }
    if (this.expander) {
      this.expander.hidden = true;
    }
    this.#initialValue = this.prop.value;
  };

  get #shouldShowComputedExpander() {
    if (this.prop.name.startsWith("--") || this.editing) {
      return false;
    }

    // Only show the expander to reveal computed properties if:
    // - the computed properties are actually different from the current property (i.e
    //   these are longhands while the current property is the shorthand)
    // - all of the computed properties have defined values. In case the current property
    //   value contains CSS variables, then the computed properties will be missing and we
    //   want to avoid showing them.
    return (
      this.prop.computed.some(c => c.name !== this.prop.name) &&
      !this.prop.computed.every(c => !c.value)
    );
  }

  get #shouldShowWarning() {
    if (this.prop.name.startsWith("--")) {
      return false;
    }

    return !this.editing && !this.isValid();
  }

  get #shouldShowInactiveCssState() {
    return (
      !this.editing &&
      !this.prop.overridden &&
      this.prop.enabled &&
      !!this.prop.getInactiveCssData()
    );
  }

  get #shouldShowFilterProperty() {
    return (
      !this.editing &&
      this.isValid() &&
      this.prop.overridden &&
      !this.rule.isUnmatched
    );
  }

  #createComputedExpander() {
    if (this.expander) {
      return;
    }

    // Click to expand the computed properties of the text property.
    this.expander = this.doc.createElementNS(HTML_NS, "button");
    this.expander.ariaExpanded = false;
    this.expander.classList.add("ruleview-expander", "theme-twisty");
    this.expander.title = SHORTHAND_EXPANDER_TOOLTIP;

    this.expander.addEventListener("click", this.#onExpandClicked, {
      capture: true,
      signal: this.abortController.signal,
    });

    this.container.insertBefore(this.expander, this.valueContainer);
  }

  #createComputedList() {
    if (this.computed) {
      return;
    }
    this.computed = this.doc.createElementNS(HTML_NS, "ul");
    this.computed.classList.add("ruleview-computedlist");
    this.element.insertBefore(this.computed, this.shorthandOverridden);
  }

  #createWarningIcon() {
    if (this.warning) {
      return;
    }

    this.warning = this.doc.createElementNS(HTML_NS, "div");
    this.warning.classList.add("ruleview-warning");
    this.warning.title = l10n("rule.warning.title");
    this.container.insertBefore(
      this.warning,
      this.invalidAtComputedValueTimeWarning ||
        this.inactiveCssState ||
        this.compatibilityState ||
        this.filterProperty
    );
  }

  #createInvalidAtComputedValueTimeIcon() {
    if (this.invalidAtComputedValueTimeWarning) {
      return;
    }

    this.invalidAtComputedValueTimeWarning = this.doc.createElementNS(
      HTML_NS,
      "div"
    );
    this.invalidAtComputedValueTimeWarning.classList.add(
      "ruleview-invalid-at-computed-value-time-warning"
    );
    this.container.insertBefore(
      this.invalidAtComputedValueTimeWarning,
      this.inactiveCssState || this.compatibilityState || this.filterProperty
    );
  }

  #createInactiveCssWarningIcon() {
    if (this.inactiveCssState) {
      return;
    }

    this.inactiveCssState = this.doc.createElementNS(HTML_NS, "div");
    this.inactiveCssState.classList.add("ruleview-inactive-css-warning");
    this.container.insertBefore(
      this.inactiveCssState,
      this.compatibilityState || this.filterProperty
    );
  }

  #createCompatibilityWarningIcon() {
    if (this.compatibilityState) {
      return;
    }

    this.compatibilityState = this.doc.createElementNS(HTML_NS, "div");
    this.compatibilityState.classList.add("ruleview-compatibility-warning");
    this.container.insertBefore(this.compatibilityState, this.filterProperty);
  }

  #createFilterPropertyButton() {
    if (this.filterProperty) {
      return;
    }

    // Filter button that filters for the current property name and is
    // displayed when the property is overridden by another rule.
    this.filterProperty = this.doc.createElementNS(HTML_NS, "button");
    this.filterProperty.classList.add("ruleview-overridden-rule-filter");
    this.filterProperty.title = l10n("rule.filterProperty.title");
    this.container.append(this.filterProperty);

    this.filterProperty.addEventListener(
      "click",
      event => {
        this.ruleView.setFilterStyles("`" + this.prop.name + "`");
        event.stopPropagation();
      },
      { signal: this.abortController.signal }
    );
  }

  /**
   * Update the visibility of the enable checkbox, the warning indicator, the used
   * indicator and the filter property, as well as the overridden state of the property.
   */
  updateUI = () => {
    if (this.prop.enabled) {
      this.enable.style.removeProperty("visibility");
    } else {
      this.enable.style.visibility = "visible";
    }

    this.enable.checked = this.prop.enabled;

    if (this.#shouldShowWarning) {
      this.element.classList.add("ruleview-invalid");

      if (!this.warning) {
        this.#createWarningIcon();
      } else {
        this.warning.hidden = false;
      }
      this.warning.title = !this.#isNameValid()
        ? l10n("rule.warningName.title")
        : l10n("rule.warning.title");
    } else {
      this.element.classList.remove("ruleview-invalid");
      if (this.warning) {
        this.warning.hidden = true;
      }
    }

    if (!this.editing && this.#isInvalidAtComputedValueTime()) {
      if (!this.invalidAtComputedValueTimeWarning) {
        this.#createInvalidAtComputedValueTimeIcon();
      }
      this.invalidAtComputedValueTimeWarning.title = l10nFormatStr(
        "rule.warningInvalidAtComputedValueTime.title",
        `"${this.prop.getExpectedSyntax()}"`
      );
      this.invalidAtComputedValueTimeWarning.hidden = false;
    } else if (this.invalidAtComputedValueTimeWarning) {
      this.invalidAtComputedValueTimeWarning.hidden = true;
    }

    if (this.#shouldShowFilterProperty) {
      if (!this.filterProperty) {
        this.#createFilterPropertyButton();
      }
      this.filterProperty.hidden = false;
    } else if (this.filterProperty) {
      this.filterProperty.hidden = true;
    }

    if (this.#shouldShowComputedExpander) {
      if (!this.expander) {
        this.#createComputedExpander();
      }
      this.expander.hidden = false;
    } else if (this.expander) {
      this.expander.hidden = true;
    }

    if (
      !this.editing &&
      (this.prop.overridden || !this.prop.enabled || !this.prop.isKnownProperty)
    ) {
      this.element.classList.add("ruleview-overridden");
    } else {
      this.element.classList.remove("ruleview-overridden");
    }

    this.#updateInactiveCssIndicator();
    this.#updatePropertyCompatibilityIndicator();
  };

  #updateInactiveCssIndicator() {
    const inactiveCssData = this.prop.getInactiveCssData();

    if (
      this.editing ||
      this.prop.overridden ||
      !this.prop.enabled ||
      !inactiveCssData
    ) {
      this.element.classList.remove("inactive-css");
      if (this.inactiveCssState) {
        this.inactiveCssState.hidden = true;
      }
    } else {
      this.element.classList.add("inactive-css");
      if (!this.inactiveCssState) {
        this.#createInactiveCssWarningIcon();
      } else {
        this.inactiveCssState.hidden = false;
      }
    }
  }

  async #updatePropertyCompatibilityIndicator() {
    const { isCompatible } = await this.prop.isCompatible();

    if (this.editing || isCompatible) {
      if (this.compatibilityState) {
        this.compatibilityState.hidden = true;
      }
    } else {
      if (!this.compatibilityState) {
        this.#createCompatibilityWarningIcon();
      }
      this.compatibilityState.hidden = false;
    }
  }

  /**
   * Update the indicator for computed styles. The computed styles themselves
   * are populated on demand, when they become visible.
   */
  #updateComputed() {
    if (this.computed) {
      this.computed.replaceChildren();
    }

    if (this.#shouldShowComputedExpander) {
      if (!this.expander) {
        this.#createComputedExpander();
      }
      this.expander.hidden = false;
    } else if (this.expander) {
      this.expander.hidden = true;
    }

    this.#populatedComputed = false;
    if (
      this.expander &&
      this.expander.getAttribute("aria-expanded" === "true")
    ) {
      this.populateComputed();
    }
  }

  /**
   * Populate the list of computed styles.
   */
  populateComputed() {
    if (this.#populatedComputed) {
      return;
    }
    this.#populatedComputed = true;

    for (const computed of this.prop.computed) {
      // Don't bother to duplicate information already
      // shown in the text property.
      if (computed.name === this.prop.name) {
        continue;
      }

      if (!this.computed) {
        this.#createComputedList();
      }

      // Store the computed style element for easy access when highlighting styles
      computed.element = this.#createComputedListItem(
        this.computed,
        computed,
        "ruleview-computed"
      );
    }
  }

  /**
   * Update the indicator for overridden shorthand styles. The shorthand
   * overridden styles themselves are populated on demand, when they
   * become visible.
   */
  #updateShorthandOverridden() {
    if (this.shorthandOverridden) {
      this.shorthandOverridden.replaceChildren();
    }

    this.#populatedShorthandOverridden = false;
    this.#populateShorthandOverridden();
  }

  /**
   * Populate the list of overridden shorthand styles.
   */
  #populateShorthandOverridden() {
    if (
      this.#populatedShorthandOverridden ||
      this.prop.overridden ||
      !this.#shouldShowComputedExpander
    ) {
      return;
    }
    this.#populatedShorthandOverridden = true;

    // Holds the viewers for the overridden shorthand properties.
    // will be populated in |#updateShorthandOverridden|.
    if (!this.shorthandOverridden) {
      this.shorthandOverridden = this.doc.createElementNS(HTML_NS, "ul");
      this.shorthandOverridden.classList.add("ruleview-overridden-items");
      this.element.append(this.shorthandOverridden);
    }

    for (const computed of this.prop.computed) {
      // Don't display duplicate information or show properties
      // that are completely overridden.
      if (computed.name === this.prop.name || !computed.overridden) {
        continue;
      }

      this.#createComputedListItem(
        this.shorthandOverridden,
        computed,
        "ruleview-overridden-item"
      );
    }
  }

  /**
   * Creates and populates a list item with the computed CSS property.
   */
  #createComputedListItem(parentEl, computed, className) {
    const li = createChild(parentEl, "li", {
      class: className,
    });

    if (computed.overridden) {
      li.classList.add("ruleview-overridden");
    }

    const nameContainer = createChild(li, "span", {
      class: "ruleview-namecontainer",
    });

    createChild(nameContainer, "span", {
      class: "ruleview-propertyname theme-fg-color3",
      textContent: computed.name,
    });
    appendText(nameContainer, ": ");

    const { outputParser } = this.ruleView;
    const frag = outputParser.parseCssProperty(computed.name, computed.value, {
      colorSwatchClass: "inspector-swatch inspector-colorswatch",
      urlClass: "theme-link",
      baseURI: this.#sheetHref,
      fontFamilyClass: "ruleview-font-family",
    });

    // Store the computed property value that was parsed for output
    computed.parsedValue = frag.textContent;

    const propertyContainer = createChild(li, "span", {
      class: "ruleview-propertyvaluecontainer",
    });

    createChild(propertyContainer, "span", {
      class: "ruleview-propertyvalue theme-fg-color1",
      child: frag,
    });
    appendText(propertyContainer, ";");

    return li;
  }

  /**
   * Handle updates to the preference which disables/enables the feature to
   * edit size properties on drag.
   */
  #onDraggablePreferenceChanged = () => {
    if (this.#isDraggableProperty(this.prop)) {
      this.#addDraggingCapability();
    } else {
      this.#removeDraggingCapacity();
    }
  };

  /**
   * Stop clicks propogating down the tree from the enable / disable checkbox.
   */
  #onEnableClicked = event => {
    event.stopPropagation();
  };

  /**
   * Handles clicks on the disabled property.
   */
  #onEnableChanged = event => {
    this.prop.setEnabled(this.enable.checked);
    event.stopPropagation();
    this.telemetry.recordEvent("edit_rule", "ruleview");
  };

  /**
   * Handles clicks on the computed property expander. If the computed list is
   * open due to user expanding or style filtering, collapse the computed list
   * and close the expander. Otherwise, add user-open attribute which is used to
   * expand the computed list and tracks whether or not the computed list is
   * expanded by manually by the user.
   */
  #onExpandClicked = event => {
    if (!this.computed) {
      // Holds the viewers for the computed properties.
      // will be populated in |#updateComputed|.
      this.#createComputedList();
    }
    const isOpened =
      this.computed.hasAttribute("filter-open") ||
      this.computed.hasAttribute("user-open");

    this.expander.setAttribute("aria-expanded", !isOpened);
    if (isOpened) {
      this.computed.removeAttribute("filter-open");
      this.computed.removeAttribute("user-open");
      if (this.shorthandOverridden) {
        this.shorthandOverridden.hidden = false;
      }
      this.#populateShorthandOverridden();
    } else {
      this.computed.setAttribute("user-open", "");
      if (this.shorthandOverridden) {
        this.shorthandOverridden.hidden = true;
      }
      this.populateComputed();
    }

    event.stopPropagation();
  };

  /**
   * Expands the computed list when a computed property is matched by the style
   * filtering. The filter-open attribute is used to track whether or not the
   * computed list was toggled opened by the filter.
   */
  expandForFilter() {
    if (!this.computed || !this.computed.hasAttribute("user-open")) {
      if (!this.expander) {
        this.#createComputedExpander();
      }
      this.expander.hidden = false;
      this.expander.setAttribute("aria-expanded", "true");

      if (!this.computed) {
        this.#createComputedList();
      }
      this.computed.setAttribute("filter-open", "");
      this.populateComputed();
    }
  }

  /**
   * Collapses the computed list that was expanded by style filtering.
   */
  collapseForFilter() {
    this.computed.removeAttribute("filter-open");

    if (!this.computed.hasAttribute("user-open") && this.expander) {
      this.expander.setAttribute("aria-expanded", "false");
    }
  }

  /**
   * Called when the property name's inplace editor is closed.
   * Ignores the change if the user pressed escape, otherwise
   * commits it.
   *
   * @param {string} value
   *        The value contained in the editor.
   * @param {boolean} commit
   *        True if the change should be applied.
   * @param {number} direction
   *        The move focus direction number.
   */
  #onNameDone = (value, commit, direction) => {
    const isNameUnchanged =
      (!commit && !this.ruleEditor.isEditing) || this.committed.name === value;
    if (this.prop.value && isNameUnchanged) {
      return;
    }

    this.telemetry.recordEvent("edit_rule", "ruleview");

    // Remove a property if the name is empty
    if (!value.trim()) {
      this.remove(direction);
      return;
    }

    const isVariable = value.startsWith("--");

    // Remove a property if:
    // - the property value is empty and is not a variable (empty variables are valid)
    // - and the property value is not about to be focused
    if (
      !this.prop.value &&
      !isVariable &&
      direction !== Services.focus.MOVEFOCUS_FORWARD
    ) {
      this.remove(direction);
      return;
    }

    // Adding multiple rules inside of name field overwrites the current
    // property with the first, then adds any more onto the property list.
    const properties = parseDeclarations(this.cssProperties.isKnown, value);

    if (properties.length) {
      this.prop.setName(properties[0].name);
      this.committed.name = this.prop.name;

      if (!this.prop.enabled) {
        this.prop.setEnabled(true);
      }

      if (properties.length > 1) {
        this.prop.setValue(properties[0].value, properties[0].priority);
        this.ruleEditor.addProperties(properties.slice(1), this.prop);
      }
    }
  };

  /**
   * Remove property from style and the editors from DOM.
   * Begin editing next or previous available property given the focus
   * direction.
   *
   * @param {number} direction
   *        The move focus direction number.
   */
  remove(direction) {
    this.rule.editClosestTextProperty(this.prop, direction);

    this.prop.remove();
    this.nameSpan.textProperty = null;
    this.valueSpan.textProperty = null;
    this.element.remove();

    this.destroy();
  }

  /**
   * Called when a value editor closes.  If the user pressed escape,
   * revert to the value this property had before editing.
   *
   * @param {string} value
   *        The value contained in the editor.
   * @param {boolean} commit
   *        True if the change should be applied.
   * @param {number} direction
   *        The move focus direction number.
   */
  #onValueDone = (value = "", commit, direction) => {
    const parsedProperties = this.#getValueAndExtraProperties(value);
    const val = parseSingleValue(
      this.cssProperties.isKnown,
      parsedProperties.firstValue
    );
    const isValueUnchanged =
      (!commit && !this.ruleEditor.isEditing) ||
      (!parsedProperties.propertiesToAdd.length &&
        this.committed.value === val.value &&
        this.committed.priority === val.priority);

    const isVariable = this.prop.name.startsWith("--");

    // If the value is not empty (or is an empty variable) and unchanged,
    // revert the property back to its original value and enabled or disabled state
    if ((value.trim() || isVariable) && isValueUnchanged) {
      const onPropertySet = this.prop.setValue(val.value, val.priority, {
        reverting: !commit,
      });
      this.rule.setPropertyEnabled(this.prop, this.prop.enabled);
      return onPropertySet;
    }

    // Check if unit of value changed to add dragging feature
    if (this.#isDraggableProperty(val)) {
      this.#addDraggingCapability();
    } else {
      this.#removeDraggingCapacity();
    }

    this.telemetry.recordEvent("edit_rule", "ruleview");

    // First, set this property value (common case, only modified a property)
    const onPropertySet = this.prop.setValue(val.value, val.priority, {
      reverting: !commit,
    });

    if (!this.prop.enabled) {
      this.prop.setEnabled(true);
    }

    this.committed.value = this.prop.value;
    this.committed.priority = this.prop.priority;

    // If needed, add any new properties after this.prop.
    this.ruleEditor.addProperties(parsedProperties.propertiesToAdd, this.prop);

    // If the input value is empty and is not a variable (empty variables are valid),
    // and the focus is moving forward to the next editable field,
    // then remove the whole property.
    // A timeout is used here to accurately check the state, since the inplace
    // editor `done` and `destroy` events fire before the next editor
    // is focused.
    if (
      !value.trim() &&
      !isVariable &&
      direction !== Services.focus.MOVEFOCUS_BACKWARD
    ) {
      setTimeout(() => {
        if (!this.editing) {
          this.remove(direction);
        }
      }, 0);
    }

    return onPropertySet;
  };

  /**
   * Called when the swatch editor wants to commit a value change.
   */
  #onSwatchCommit = () => {
    this.#onValueDone(this.valueSpan.textContent, true);
    this.update();
  };

  /**
   * Called when the swatch editor wants to preview a value change.
   */
  #onSwatchPreview = () => {
    this.#previewValue(this.valueSpan.textContent);
  };

  /**
   * Called when the swatch editor closes from an ESC. Revert to the original
   * value of this property before editing.
   */
  #onSwatchRevert = () => {
    this.#previewValue(this.#initialValue, true);
    this.update();
  };

  /**
   * Parse a value string and break it into pieces, starting with the
   * first value, and into an array of additional properties (if any).
   *
   * Example: Calling with "red; width: 100px" would return
   * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] }
   *
   * @param {string} value
   *        The string to parse
   * @return {object} An object with the following properties:
   *        firstValue: A string containing a simple value, like
   *                    "red" or "100px!important"
   *        propertiesToAdd: An array with additional properties, following the
   *                         parseDeclarations format of {name,value,priority}
   */
  #getValueAndExtraProperties(value) {
    // The inplace editor will prevent manual typing of multiple properties,
    // but we need to deal with the case during a paste event.
    // Adding multiple properties inside of value editor sets value with the
    // first, then adds any more onto the property list (below this property).
    let firstValue = value;
    let propertiesToAdd = [];

    const properties = parseDeclarations(this.cssProperties.isKnown, value);

    // Check to see if the input string can be parsed as multiple properties
    if (properties.length) {
      // Get the first property value (if any), and any remaining
      // properties (if any)
      if (!properties[0].name && properties[0].value) {
        firstValue = properties[0].value;
        propertiesToAdd = properties.slice(1);
      } else if (properties[0].name && properties[0].value) {
        // In some cases, the value could be a property:value pair
        // itself.  Join them as one value string and append
        // potentially following properties
        firstValue = properties[0].name + ": " + properties[0].value;
        propertiesToAdd = properties.slice(1);
      }
    }

    return {
      propertiesToAdd,
      firstValue,
    };
  }

  /**
   * Boolean indicating if the user is potentially previewing another value
   * in a swatch tooltip, or in the inplace value editor
   */
  get isPreviewing() {
    return this.ruleView.tooltips.isEditing || this.valueSpan.inplaceEditor;
  }

  /**
   * Live preview this property, without committing changes.
   *
   * @param {string} value
   *        The value to set the current property to.
   * @param {boolean} reverting
   *        True if we're reverting the previously previewed value
   */
  #previewValue = (value, reverting = false) => {
    // Since function call is debounced, we need to make sure we are still
    // editing, and any selector modifications have been completed
    if (!reverting && (!this.isPreviewing || this.ruleEditor.isEditing)) {
      return;
    }

    const val = parseSingleValue(this.cssProperties.isKnown, value);
    // Note that the following instruction is async
    this.prop.setValue(val.value, val.priority, { reverting });
  };

  /**
   * Check if the event passed has a "small increment" modifier
   * Alt on macosx and ctrl on other OSs
   *
   * @param  {KeyboardEvent} event
   * @returns {boolean}
   */
  #hasSmallIncrementModifier(event) {
    const modifier =
      lazy.AppConstants.platform === "macosx" ? "altKey" : "ctrlKey";
    return event[modifier] === true;
  }

  /**
   * Parses the value to check if it is a dimension
   * e.g. if the input is "128px" it will return an object like
   * { groups: { value: "128", unit: "px"}}
   *
   * @param  {string} value
   * @returns {object | null}
   */
  #parseDimension(value) {
    // The regex handles values like +1, -1, 1e4, .4, 1.3e-4, 1.567
    const cssDimensionRegex =
      /^(?<value>[+-]?(\d*\.)?\d+(e[+-]?\d+)?)(?<unit>(%|[a-zA-Z]+))$/;
    return value.match(cssDimensionRegex);
  }

  /**
   * Check if a textProperty value is supported to add the dragging feature
   *
   * @param  {TextProperty} textProperty
   * @returns {boolean}
   */
  #isDraggableProperty(textProperty) {
    // Check if the feature is explicitly disabled.
    if (!this.ruleView.draggablePropertiesEnabled) {
      return false;
    }
    // temporary way of fixing the bug when editing inline styles
    // otherwise the textPropertyEditor object is destroyed on each value edit
    // See Bug 1755024
    if (this.rule.domRule.type == ELEMENT_STYLE) {
      return false;
    }

    const nbValues = textProperty.value.split(" ").length;
    if (nbValues > 1) {
      // we do not support values like "1px solid red" yet
      // See 1755025
      return false;
    }

    const dimensionMatchObj = this.#parseDimension(textProperty.value);
    return !!dimensionMatchObj;
  }

  #draggingOnPointerDown = event => {
    if (!canPointerEventDrag(event)) {
      return;
    }

    this.#isDragging = true;
    this.valueSpan.setPointerCapture(event.pointerId);
    this.#capturingPointerId = event.pointerId;
    this.#draggingController = new AbortController();
    const { signal } = this.#draggingController;

    // turn off user-select in CSS when we drag
    this.valueSpan.classList.add(IS_DRAGGING_CLASSNAME);

    const dimensionObj = this.#parseDimension(this.prop.value);
    const { value, unit } = dimensionObj.groups;
    // `pointerdown.screenX` may be fractional value and we will compare it
    // with `mousemove.screenX` which is always integer value and the difference
    // will be used to compute the new value.  For making the difference always
    // integer, we need to convert it to integer value with the same as
    // MouseEvent does.
    // https://searchfox.org/mozilla-central/rev/7857ea04d142f2abc0d777085f9e54526c7cedf9/dom/events/MouseEvent.cpp#314
    // https://searchfox.org/mozilla-central/rev/7857ea04d142f2abc0d777085f9e54526c7cedf9/gfx/2d/Point.h#85-86
    const intScreenX = Math.floor(event.screenX + 0.5);
    this.#draggingValueCache = {
      isInDeadzone: true,
      previousScreenX: intScreenX,
      value: parseFloat(value),
      unit,
    };

    // "pointermove" is fired when the button state is changed too.  Therefore,
    // we should listen to "mousemove" to handle the pointer position changes.
    this.valueSpan.addEventListener("mousemove", this.#draggingOnMouseMove, {
      signal,
    });
    this.valueSpan.addEventListener("pointerup", this.#draggingOnPointerUp, {
      signal,
    });
    this.valueSpan.addEventListener("keydown", this.#draggingOnKeydown, {
      signal,
    });
  };

  #draggingOnMouseMove = throttle(async event => {
    if (!this.#isDragging) {
      return;
    }

    const { isInDeadzone, previousScreenX } = this.#draggingValueCache;
    let deltaX = event.screenX - previousScreenX;

    // If `isInDeadzone` is still true, the user has not previously left the deadzone.
    if (isInDeadzone) {
      // If the mouse is still in the deadzone, bail out immediately.
      if (Math.abs(deltaX) < DRAGGING_DEADZONE_DISTANCE) {
        return;
      }

      // Otherwise, remove the DRAGGING_DEADZONE_DISTANCE from the current deltaX, so that
      // the value does not update too abruptly.
      deltaX =
        Math.sign(deltaX) * (Math.abs(deltaX) - DRAGGING_DEADZONE_DISTANCE);

      // Update the state to remember the user is out of the deadzone.
      this.#draggingValueCache.isInDeadzone = false;
    }

    let draggingSpeed = DEFAULT_DRAGGING_SPEED;
    if (event.shiftKey) {
      draggingSpeed = FAST_DRAGGING_SPEED;
    } else if (this.#hasSmallIncrementModifier(event)) {
      draggingSpeed = SLOW_DRAGGING_SPEED;
    }

    const delta = deltaX * draggingSpeed;
    this.#draggingValueCache.previousScreenX = event.screenX;
    this.#draggingValueCache.value += delta;

    if (delta == 0) {
      return;
    }

    const { value, unit } = this.#draggingValueCache;
    // We use toFixed to avoid the case where value is too long, 9.00001px for example
    const roundedValue = Number.isInteger(value) ? value : value.toFixed(1);
    const onValueSet = this.prop.setValue(
      roundedValue + unit,
      this.prop.priority
    );
    this.#hasDragged = true;
    await onValueSet;
    this.ruleView.emitForTests("property-updated-by-dragging");
  }, 30);

  #draggingOnPointerUp = () => {
    if (!this.#isDragging) {
      return;
    }
    if (this.#hasDragged) {
      this.committed.value = this.prop.value;
      this.prop.setEnabled(true);
    }
    this.#onStopDragging();
  };

  #draggingOnKeydown = event => {
    if (event.key == "Escape") {
      this.prop.setValue(this.committed.value, this.committed.priority, {
        reverting: true,
      });
      this.#onStopDragging();
      event.preventDefault();
    }
  };

  #onStopDragging() {
    // childHasDragged is used to stop the propagation of a click event when we
    // release the mouse in the ruleview.
    // The click event is not emitted when we have a pending click on the text property.
    if (this.#hasDragged && !this.#hasPendingClick) {
      this.ruleView.childHasDragged = true;
    }
    this.#isDragging = false;
    this.#hasDragged = false;
    this.#draggingValueCache = null;
    if (this.#capturingPointerId !== null) {
      this.#capturingPointerId = null;
      try {
        this.valueSpan.releasePointerCapture(this.#capturingPointerId);
      } catch (e) {
        // Ignore exception even if the pointerId has already been invalidated
        // before the capture has already been released implicitly.
      }
    }
    this.valueSpan.classList.remove(IS_DRAGGING_CLASSNAME);
    this.#draggingController.abort();
  }

  /**
   * add event listeners to add the ability to modify any size value
   * by dragging the mouse horizontally
   */
  #addDraggingCapability() {
    if (this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) {
      return;
    }
    this.valueSpan.classList.add(DRAGGABLE_VALUE_CLASSNAME);
    this.valueSpan.addEventListener(
      "pointerdown",
      this.#draggingOnPointerDown,
      { passive: true }
    );
  }

  #removeDraggingCapacity() {
    if (!this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) {
      return;
    }
    this.#draggingController = null;
    this.valueSpan.classList.remove(DRAGGABLE_VALUE_CLASSNAME);
    this.valueSpan.removeEventListener(
      "pointerdown",
      this.#draggingOnPointerDown,
      { passive: true }
    );
  }

  /**
   * Validate this property. Does it make sense for this value to be assigned
   * to this property name? This does not apply the property value
   *
   * @return {boolean} true if the property name + value pair is valid, false otherwise.
   */
  isValid() {
    return this.prop.isValid();
  }

  /**
   * Validate the name of this property.
   *
   * @return {boolean} true if the property name is valid, false otherwise.
   */
  #isNameValid() {
    return this.prop.isNameValid();
  }

  #isInvalidAtComputedValueTime() {
    return this.prop.isInvalidAtComputedValueTime();
  }

  /**
   * Display grid-template-area value strings each on their own line
   * to display it in an ascii-art style matrix
   */
  #formatGridTemplateAreasValue() {
    this.valueSpan.classList.add("ruleview-propertyvalue-break-spaces");

    let quoteSymbolsUsed = [];

    const getQuoteSymbolsUsed = cssValue => {
      const regex = /\"|\'/g;
      const found = cssValue.match(regex);
      quoteSymbolsUsed = found.filter((_, i) => i % 2 === 0);
    };

    getQuoteSymbolsUsed(this.valueSpan.innerText);

    this.valueSpan.innerText = this.valueSpan.innerText
      .split('"')
      .filter(s => s !== "")
      .map(s => s.split("'"))
      .flat()
      .map(s => s.trim().replace(/\s+/g, " "))
      .filter(s => s.length)
      .map(line => line.split(" "))
      .map((line, i, lines) =>
        line.map((col, j) =>
          col.padEnd(Math.max(...lines.map(l => l[j]?.length || 0)), " ")
        )
      )
      .map(
        (line, i) =>
          `\n${quoteSymbolsUsed[i]}` + line.join(" ") + quoteSymbolsUsed[i]
      )
      .join(" ");
  }

  destroy() {
    this.#removeSwatches();

    if (this.abortController) {
      this.abortController.abort();
      this.abortController = null;
    }

    this.#elementsWithPendingClicks.delete(this.valueSpan);
  }
}

module.exports = TextPropertyEditor;
