/* 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 } = 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");

loader.lazyRequireGetter(
  this,
  "openContentLink",
  "resource://devtools/client/shared/link.js",
  true
);
loader.lazyRequireGetter(
  this,
  ["parseDeclarations", "parseSingleValue"],
  "resource://devtools/shared/css/parsing-utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "findCssSelector",
  "resource://devtools/shared/inspector/css-logic.js",
  true
);
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 = "ruleview-swatch";
const COLOR_SWATCH_CLASS = "ruleview-colorswatch";
const BEZIER_SWATCH_CLASS = "ruleview-bezierswatch";
const LINEAR_EASING_SWATCH_CLASS = "ruleview-lineareasingswatch";
const FILTER_SWATCH_CLASS = "ruleview-filterswatch";
const ANGLE_SWATCH_CLASS = "ruleview-angleswatch";
const FONT_FAMILY_CLASS = "ruleview-font-family";
const SHAPE_SWATCH_CLASS = "ruleview-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";

// In order to highlight the used fonts in font-family properties, we
// retrieve the list of used fonts from the server. That always
// returns the actually used font family name(s). If the property's
// authored value is sans-serif for instance, the used font might be
// arial instead.  So we need the list of all generic font family
// names to underline those when we find them.
const GENERIC_FONT_FAMILIES = [
  "serif",
  "sans-serif",
  "cursive",
  "fantasy",
  "monospace",
  "system-ui",
];

/**
 * 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.
 */
function TextPropertyEditor(ruleEditor, property) {
  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._populatedComputed = false;
  this._hasPendingClick = false;
  this._clickedElementOptions = null;

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

  this._isDragging = false;
  this._hasDragged = false;
  this._draggingController = null;
  this._draggingValueCache = null;

  this.getGridlineNames = this.getGridlineNames.bind(this);
  this.update = this.update.bind(this);
  this.updatePropertyState = this.updatePropertyState.bind(this);
  this._onDraggablePreferenceChanged =
    this._onDraggablePreferenceChanged.bind(this);
  this._onEnableChanged = this._onEnableChanged.bind(this);
  this._onEnableClicked = this._onEnableClicked.bind(this);
  this._onExpandClicked = this._onExpandClicked.bind(this);
  this._onNameDone = this._onNameDone.bind(this);
  this._onStartEditing = this._onStartEditing.bind(this);
  this._onSwatchCommit = this._onSwatchCommit.bind(this);
  this._onSwatchPreview = this._onSwatchPreview.bind(this);
  this._onSwatchRevert = this._onSwatchRevert.bind(this);
  this._onValidate = this.ruleView.debounce(this._previewValue, 10, this);
  this._onValueDone = this._onValueDone.bind(this);

  this._draggingOnMouseDown = this._draggingOnMouseDown.bind(this);
  this._draggingOnMouseMove = throttle(this._draggingOnMouseMove, 30, this);
  this._draggingOnMouseUp = this._draggingOnMouseUp.bind(this);
  this._draggingOnKeydown = this._draggingOnKeydown.bind(this);

  this._create();
  this.update();
}

TextPropertyEditor.prototype = {
  /**
   * Boolean indicating if the name or value is being currently edited.
   */
  get editing() {
    return (
      !!(
        this.nameSpan.inplaceEditor ||
        this.valueSpan.inplaceEditor ||
        this.ruleView.tooltips.isEditing
      ) || this.popup.isOpen
    );
  },

  /**
   * Get the rule 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.
   */
  _create() {
    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.ruleEditor.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",
      "aria-labelledby": this.prop.id,
      tabindex: "-1",
    });

    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, ": ");

    // Click to expand the computed properties of the text property.
    this.expander = createChild(this.container, "span", {
      class: "ruleview-expander theme-twisty",
    });
    this.expander.addEventListener("click", this._onExpandClicked, true);

    // 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.warning = createChild(this.container, "div", {
      class: "ruleview-warning",
      hidden: "",
      title: l10n("rule.warning.title"),
    });

    this.unusedState = createChild(this.container, "div", {
      class: "ruleview-unused-warning",
      hidden: "",
    });

    this.compatibilityState = createChild(this.container, "div", {
      class: "ruleview-compatibility-warning",
      hidden: "",
    });

    // Filter button that filters for the current property name and is
    // displayed when the property is overridden by another rule.
    this.filterProperty = createChild(this.container, "div", {
      class: "ruleview-overridden-rule-filter",
      hidden: "",
      title: l10n("rule.filterProperty.title"),
    });

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

    // Holds the viewers for the computed properties.
    // will be populated in |_updateComputed|.
    this.computed = createChild(this.element, "ul", {
      class: "ruleview-computedlist",
    });

    // Holds the viewers for the overridden shorthand properties.
    // will be populated in |_updateShorthandOverridden|.
    this.shorthandOverridden = createChild(this.element, "ul", {
      class: "ruleview-overridden-items",
    });

    // Only bind event handlers if the rule is editable.
    if (this.ruleEditor.isEditable) {
      this.enable.addEventListener("click", this._onEnableClicked, true);
      this.enable.addEventListener("change", this._onEnableChanged, 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();
        }
      });

      editableField({
        start: this._onStartEditing,
        element: this.nameSpan,
        done: this._onNameDone,
        destroy: this.updatePropertyState,
        advanceChars: ":",
        contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
        popup: this.popup,
        cssProperties: this.cssProperties,
      });

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

      this.valueContainer.addEventListener("click", 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();
        }
      });

      // 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;

        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),
          };
        }
      });

      this.valueSpan.addEventListener("mouseup", event => {
        // if we have dragged, we will handle the pending click in _draggingOnMouseUp instead
        if (this._hasDragged) {
          return;
        }
        this._clickedElementOptions = null;
        this._hasPendingClick = false;
      });

      this.valueSpan.addEventListener("click", event => {
        const target = event.target;

        if (target.nodeName === "a") {
          event.stopPropagation();
          event.preventDefault();
          openContentLink(target.href);
        }
      });

      this.ruleView.on(
        "draggable-preference-updated",
        this._onDraggablePreferenceChanged
      );
      if (this._isDraggableProperty(this.prop)) {
        this._addDraggingCapability();
      }

      editableField({
        start: this._onStartEditing,
        element: this.valueSpan,
        done: this._onValueDone,
        destroy: this.update,
        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,
        cssVariables:
          this.rule.elementStyle.variablesMap.get(this.rule.pseudoElement) ||
          [],
        getGridLineNames: this.getGridlineNames,
        showSuggestCompletionOnEmpty: true,
      });
    }
  },

  /**
   * 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: []}.
   */
  async getGridlineNames() {
    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.updatePropertyState();

    const name = this.prop.name;
    this.nameSpan.textContent = 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 = store.userProperties.contains(this.rule.domRule, name);

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

    const outputParser = this.ruleView._outputParser;
    const parserOptions = {
      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: "ruleview-flex js-toggle-flexbox-highlighter",
      gridClass: "ruleview-grid js-toggle-grid-highlighter",
      linearEasingClass: "ruleview-lineareasing",
      linearEasingSwatchClass:
        SHARED_SWATCH_CLASS + " " + LINEAR_EASING_SWATCH_CLASS,
      shapeClass: "ruleview-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,
      unmatchedVariableClass: "ruleview-unmatched-variable",
      matchedVariableClass: "ruleview-variable",
      getVariableValue: varName =>
        this.rule.elementStyle.getVariable(varName, this.rule.pseudoElement),
    };
    const frag = outputParser.parseCssProperty(name, val, parserOptions);

    // 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,
      };
    }

    // Save focused element inside value span if one exists before wiping the innerHTML
    let focusedElSelector = null;
    if (this.valueSpan.contains(this.doc.activeElement)) {
      focusedElSelector = findCssSelector(this.doc.activeElement);
    }

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

    this.ruleView.emit("property-value-updated", {
      rule: this.prop.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.rule.elementStyle
        .getUsedFontFamilies()
        .then(families => {
          const usedFontFamilies = families.map(font => font.toLowerCase());
          let foundMatchingFamily = false;
          let firstGenericSpan = null;

          for (const span of fontFamilySpans) {
            const authoredFont = span.textContent.toLowerCase();

            if (
              !firstGenericSpan &&
              GENERIC_FONT_FAMILIES.includes(authoredFont)
            ) {
              firstGenericSpan = span;
            }

            if (usedFontFamilies.includes(authoredFont)) {
              span.classList.add("used-font");
              foundMatchingFamily = true;
            }
          }

          if (!foundMatchingFamily && firstGenericSpan) {
            firstGenericSpan.classList.add("used-font");
          }

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

    // Attach the color picker tooltip to the color swatches
    this._colorSwatchSpans = this.valueSpan.querySelectorAll(
      "." + COLOR_SWATCH_CLASS
    );
    if (this.ruleEditor.isEditable) {
      for (const span of this._colorSwatchSpans) {
        // Adding this swatch to the list of swatches our colorpicker
        // knows about
        this.ruleView.tooltips.getTooltip("colorPicker").addSwatch(span, {
          onShow: this._onStartEditing,
          onPreview: this._onSwatchPreview,
          onCommit: this._onSwatchCommit,
          onRevert: this._onSwatchRevert,
        });
        const title = l10n("rule.colorSwatch.tooltip");
        span.setAttribute("title", title);
        span.dataset.propertyName = this.nameSpan.textContent;
      }
    }

    // Attach the cubic-bezier tooltip to the bezier swatches
    this._bezierSwatchSpans = this.valueSpan.querySelectorAll(
      "." + BEZIER_SWATCH_CLASS
    );
    if (this.ruleEditor.isEditable) {
      for (const span of this._bezierSwatchSpans) {
        // Adding this swatch to the list of swatches our colorpicker
        // knows about
        this.ruleView.tooltips.getTooltip("cubicBezier").addSwatch(span, {
          onShow: this._onStartEditing,
          onPreview: this._onSwatchPreview,
          onCommit: this._onSwatchCommit,
          onRevert: this._onSwatchRevert,
        });
        const title = l10n("rule.bezierSwatch.tooltip");
        span.setAttribute("title", title);
      }
    }

    // Attach the linear easing tooltip to the linear easing swatches
    this._linearEasingSwatchSpans = this.valueSpan.querySelectorAll(
      "." + LINEAR_EASING_SWATCH_CLASS
    );
    if (this.ruleEditor.isEditable) {
      for (const span of this._linearEasingSwatchSpans) {
        // Adding this swatch to the list of swatches our colorpicker
        // knows about
        this.ruleView.tooltips
          .getTooltip("linearEaseFunction")
          .addSwatch(span, {
            onShow: this._onStartEditing,
            onPreview: this._onSwatchPreview,
            onCommit: this._onSwatchCommit,
            onRevert: this._onSwatchRevert,
          });
        span.setAttribute("title", l10n("rule.bezierSwatch.tooltip"));
      }
    }

    // Attach the filter editor tooltip to the filter swatch
    const span = this.valueSpan.querySelector("." + FILTER_SWATCH_CLASS);
    if (this.ruleEditor.isEditable) {
      if (span) {
        parserOptions.filterSwatch = true;

        this.ruleView.tooltips.getTooltip("filterEditor").addSwatch(
          span,
          {
            onShow: this._onStartEditing,
            onPreview: this._onSwatchPreview,
            onCommit: this._onSwatchCommit,
            onRevert: this._onSwatchRevert,
          },
          outputParser,
          parserOptions
        );
        const title = l10n("rule.filterSwatch.tooltip");
        span.setAttribute("title", title);
      }
    }

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

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

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

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

    const shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch");
    if (shapeToggle) {
      const mode =
        "css" +
        name
          .split("-")
          .map(s => {
            return s[0].toUpperCase() + s.slice(1);
          })
          .join("");
      shapeToggle.setAttribute("data-mode", mode);
    }

    // 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;
      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);

    // Restore focus back to the element whose markup was recreated above.
    if (focusedElSelector) {
      const elementToFocus = this.doc.querySelector(focusedElSelector);
      if (elementToFocus) {
        elementToFocus.focus();
      }
    }
  },

  _onStartEditing() {
    this.element.classList.remove("ruleview-overridden");
    this.filterProperty.hidden = true;
    this.enable.style.visibility = "hidden";
    this.expander.style.display = "none";
  },

  get shouldShowComputedExpander() {
    // 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)
    );
  },

  /**
   * 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.
   */
  updatePropertyState() {
    if (this.prop.enabled) {
      this.enable.style.removeProperty("visibility");
    } else {
      this.enable.style.visibility = "visible";
    }

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

    this.warning.title = !this.isNameValid()
      ? l10n("rule.warningName.title")
      : l10n("rule.warning.title");

    this.warning.hidden = this.editing || this.isValid();
    this.filterProperty.hidden =
      this.editing ||
      !this.isValid() ||
      !this.prop.overridden ||
      this.ruleEditor.rule.isUnmatched;

    this.expander.style.display = this.shouldShowComputedExpander
      ? "inline-block"
      : "none";

    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.updatePropertyUsedIndicator();
    this.updatePropertyCompatibilityIndicator();
  },

  updatePropertyUsedIndicator() {
    const { used } = this.prop.isUsed();

    if (this.editing || this.prop.overridden || !this.prop.enabled || used) {
      this.element.classList.remove("unused");
      this.unusedState.hidden = true;
    } else {
      this.element.classList.add("unused");
      this.unusedState.hidden = false;
    }
  },

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

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

  /**
   * Update the indicator for computed styles. The computed styles themselves
   * are populated on demand, when they become visible.
   */
  _updateComputed() {
    this.computed.innerHTML = "";

    this.expander.style.display =
      !this.editing && this.shouldShowComputedExpander
        ? "inline-block"
        : "none";

    this._populatedComputed = false;
    if (this.expander.hasAttribute("open")) {
      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;
      }

      // 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() {
    this.shorthandOverridden.innerHTML = "";

    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;

    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._outputParser;
    const frag = outputParser.parseCssProperty(computed.name, computed.value, {
      colorSwatchClass: "ruleview-swatch ruleview-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.hasAttribute("filter-open") ||
      this.computed.hasAttribute("user-open")
    ) {
      this.expander.removeAttribute("open");
      this.computed.removeAttribute("filter-open");
      this.computed.removeAttribute("user-open");
      this.shorthandOverridden.hidden = false;
      this._populateShorthandOverridden();
    } else {
      this.expander.setAttribute("open", "true");
      this.computed.setAttribute("user-open", "");
      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.hasAttribute("user-open")) {
      this.expander.setAttribute("open", "true");
      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.removeAttribute("open");
    }
  },

  /**
   * 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;
    }

    // Remove a property if the property value is empty and the property
    // value is not about to be focused
    if (!this.prop.value && 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) {
    if (this._colorSwatchSpans && this._colorSwatchSpans.length) {
      for (const span of this._colorSwatchSpans) {
        this.ruleView.tooltips.getTooltip("colorPicker").removeSwatch(span);
      }
    }

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

    this.ruleView.off(
      "draggable-preference-updated",
      this._onDraggablePreferenceChanged
    );

    this.element.remove();
    this.ruleEditor.rule.editClosestTextProperty(this.prop, direction);
    this.nameSpan.textProperty = null;
    this.valueSpan.textProperty = null;
    this.prop.remove();
  },

  /**
   * 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);

    // If the value is not empty and unchanged, revert the property back to
    // its original value and enabled or disabled state
    if (value.trim() && isValueUnchanged) {
      this.ruleEditor.rule.previewPropertyValue(
        this.prop,
        val.value,
        val.priority
      );
      this.rule.setPropertyEnabled(this.prop, this.prop.enabled);
      return;
    }

    // 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)
    this.prop.setValue(val.value, val.priority);

    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 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() && direction !== Services.focus.MOVEFOCUS_BACKWARD) {
      setTimeout(() => {
        if (!this.editing) {
          this.remove(direction);
        }
      }, 0);
    }
  },

  /**
   * 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.prop.value, 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,
    };
  },

  /**
   * 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.editing || this.ruleEditor.isEditing)) {
      return;
    }

    const val = parseSingleValue(this.cssProperties.isKnown, value);
    this.ruleEditor.rule.previewPropertyValue(
      this.prop,
      val.value,
      val.priority
    );
  },

  /**
   * 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;
  },

  _draggingOnMouseDown(event) {
    this._isDragging = true;
    this.valueSpan.setPointerCapture(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;
    this._draggingValueCache = {
      isInDeadzone: true,
      previousScreenX: event.screenX,
      value: parseFloat(value),
      unit,
    };

    this.valueSpan.addEventListener("mousemove", this._draggingOnMouseMove, {
      signal,
    });
    this.valueSpan.addEventListener("mouseup", this._draggingOnMouseUp, {
      signal,
    });
    this.valueSpan.addEventListener("keydown", this._draggingOnKeydown, {
      signal,
    });
  },

  _draggingOnMouseMove(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);
    this.prop.setValue(roundedValue + unit, this.prop.priority);
    this.ruleView.emitForTests("property-updated-by-dragging");
    this._hasDragged = true;
  },

  _draggingOnMouseUp(event) {
    if (!this._isDragging) {
      return;
    }
    if (this._hasDragged) {
      this.committed.value = this.prop.value;
      this.prop.setEnabled(true);
    }
    this._onStopDragging(event);
  },

  _draggingOnKeydown(event) {
    if (event.key == "Escape") {
      this.prop.setValue(this.committed.value, this.committed.priority);
      this._onStopDragging(event);
      event.preventDefault();
    }
  },

  _onStopDragging(event) {
    // 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;
    this.valueSpan.releasePointerCapture(event.pointerId);
    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("mousedown", this._draggingOnMouseDown);
  },

  _removeDraggingCapacity() {
    if (!this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) {
      return;
    }
    this._draggingController = null;
    this.valueSpan.classList.remove(DRAGGABLE_VALUE_CLASSNAME);
    this.valueSpan.removeEventListener("mousedown", this._draggingOnMouseDown);
  },

  /**
   * 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();
  },

  /**
   * 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)), " ")
        )
      )
      .map(
        (line, i) =>
          `\n${quoteSymbolsUsed[i]}` + line.join(" ") + quoteSymbolsUsed[i]
      )
      .join(" ");
  },
};

module.exports = TextPropertyEditor;
