/* 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/. */

/**
 * Basic use:
 * let spanToEdit = document.getElementById("somespan");
 *
 * editableField({
 *   element: spanToEdit,
 *   done: function(value, commit, direction, key) {
 *     if (commit) {
 *       spanToEdit.textContent = value;
 *     }
 *   },
 *   trigger: "dblclick"
 * });
 *
 * See editableField() for more options.
 */

"use strict";

const focusManager = Services.focus;
const isOSX = Services.appinfo.OS === "Darwin";
const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const {
  findMostRelevantCssPropertyIndex,
} = require("resource://devtools/client/shared/suggestion-picker.js");

loader.lazyRequireGetter(
  this,
  "InspectorCSSParserWrapper",
  "resource://devtools/shared/css/lexer.js",
  true
);

const HTML_NS = "http://www.w3.org/1999/xhtml";
const CONTENT_TYPES = {
  PLAIN_TEXT: 0,
  CSS_VALUE: 1,
  CSS_MIXED: 2,
  CSS_PROPERTY: 3,
};

// The limit of 500 autocomplete suggestions should not be reached but is kept
// for safety.
const MAX_POPUP_ENTRIES = 500;

const FOCUS_FORWARD = focusManager.MOVEFOCUS_FORWARD;
const FOCUS_BACKWARD = focusManager.MOVEFOCUS_BACKWARD;

const WORD_REGEXP = /\w/;
const isWordChar = function (str) {
  return str && WORD_REGEXP.test(str);
};

const GRID_PROPERTY_NAMES = [
  "grid-area",
  "grid-row",
  "grid-row-start",
  "grid-row-end",
  "grid-column",
  "grid-column-start",
  "grid-column-end",
];
const GRID_ROW_PROPERTY_NAMES = [
  "grid-area",
  "grid-row",
  "grid-row-start",
  "grid-row-end",
];
const GRID_COL_PROPERTY_NAMES = [
  "grid-area",
  "grid-column",
  "grid-column-start",
  "grid-column-end",
];

/**
 * Helper to check if the provided key matches one of the expected keys.
 * Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes.
 *
 * @param {string} key
 *        the key to check (can be a keyCode).
 * @param {...string} keys
 *        list of possible keys allowed.
 * @return {boolean} true if the key matches one of the keys.
 */
function isKeyIn(key, ...keys) {
  return keys.some(expectedKey => {
    return key === KeyCodes["DOM_VK_" + expectedKey];
  });
}

/**
 * Mark a span editable.  |editableField| will listen for the span to
 * be focused and create an InlineEditor to handle text input.
 * Changes will be committed when the InlineEditor's input is blurred
 * or dropped when the user presses escape.
 *
 * @param {object} options: Options for the editable field
 * @param {Element} options.element:
 *        (required) The span to be edited on focus.
 * @param {string} options.inputClass:
 *        An optional class to be added to the input.
 * @param {Function} options.canEdit:
 *        Will be called before creating the inplace editor.  Editor
 *        won't be created if canEdit returns false.
 * @param {Function} options.start:
 *        Will be called when the inplace editor is initialized.
 * @param {Function} options.change:
 *        Will be called when the text input changes.  Will be called
 *        with the current value of the text input.
 * @param {Function} options.done:
 *        Called when input is committed or blurred.  Called with
 *        current value, a boolean telling the caller whether to
 *        commit the change, the direction of the next element to be
 *        selected and the event keybode. Direction may be one of Services.focus.MOVEFOCUS_FORWARD,
 *        Services.focus.MOVEFOCUS_BACKWARD, or null (no movement).
 *        This function is called before the editor has been torn down.
 * @param {Function} options.destroy:
 *        Called when the editor is destroyed and has been torn down.
 *        This may be called with the return value of the options.done callback (if it is passed).
 * @param {Function} options.contextMenu:
 *        Called when the user triggers a contextmenu event on the input.
 * @param {object} options.advanceChars:
 *        This can be either a string or a function.
 *        If it is a string, then if any characters in it are typed,
 *        focus will advance to the next element.
 *        Otherwise, if it is a function, then the function will
 *        be called with three arguments: a key code, the current text,
 *        and the insertion point.  If the function returns true,
 *        then the focus advance takes place.  If it returns false,
 *        then the character is inserted instead.
 * @param {boolean} options.stopOnReturn:
 *        If true, the return key will not advance the editor to the next
 *        focusable element. Note that Ctrl/Cmd+Enter will still advance the editor
 * @param {boolean} options.stopOnTab:
 *        If true, the tab key will not advance the editor to the next
 *        focusable element.
 * @param {boolean} options.stopOnShiftTab:
 *        If true, shift tab will not advance the editor to the previous
 *        focusable element.
 * @param {string} options.trigger: The DOM event that should trigger editing,
 *        defaults to "click"
 * @param {boolean} options.multiline: Should the editor be a multiline textarea?
 *        defaults to false
 * @param {Function or options.Number} maxWidth:
 *        Should the editor wrap to remain below the provided max width. Only
 *        available if multiline is true. If a function is provided, it will be
 *        called when replacing the element by the inplace input.
 * @param {boolean} options.trimOutput: Should the returned string be trimmed?
 *        defaults to true
 * @param {boolean} options.preserveTextStyles: If true, do not copy text-related styles
 *        from `element` to the new input.
 *        defaults to false
 * @param {object} options.cssProperties: An instance of CSSProperties.
 * @param {object} options.getCssVariables: A function that returns a Map containing
 *        all CSS variables. The Map key is the variable name, the value is the variable value
 * @param {number} options.defaultIncrement: The value by which the input is incremented
 *        or decremented by default (0.1 for properties like opacity and 1 by default)
 * @param {Function} options.getGridLineNames:
 *        Will be called before offering autocomplete sugestions, if the property is
 *        a member of GRID_PROPERTY_NAMES.
 * @param {boolean} options.showSuggestCompletionOnEmpty:
 *        If true, show the suggestions in case that the current text becomes empty.
 *        Defaults to false.
 * @param {boolean} options.focusEditableFieldAfterApply
 *        If true, try to focus the next editable field after the input value is commited.
 *        When set to true, focusEditableFieldContainerSelector is mandatory.
 *        If no editable field can be found within the element retrieved with
 *        focusEditableFieldContainerSelector, the focus will be moved to the next focusable
 *        element (which won't be an editable field)
 * @param {string} options.focusEditableFieldContainerSelector
 *        A CSS selector that will be used to retrieve the container element into which
 *        the next focused element should be in, when focusEditableFieldAfterApply
 *        is set to true. This allows to bail out if we can't find a suitable
 *        focusable field.
 * @param {string} options.inputAriaLabel
 *        Optional aria-label attribute value that will be added to the input.
 * @param {string} options.inputAriaLabelledBy
 *        Optional aria-labelled-by attribute value that will be added to the input.
 */
function editableField(options) {
  return editableItem(options, function (element, event) {
    if (!options.element.inplaceEditor) {
      new InplaceEditor(options, event);
    }
  });
}

exports.editableField = editableField;

/**
 * Handle events for an element that should respond to
 * clicks and sit in the editing tab order, and call
 * a callback when it is activated.
 *
 * @param {object} options
 *    The options for this editor, including:
 *    {Element} element: The DOM element.
 *    {String} trigger: The DOM event that should trigger editing,
 *      defaults to "click"
 * @param {Function} callback
 *        Called when the editor is activated.
 * @return {Function} function which calls callback
 */
function editableItem(options, callback) {
  const trigger = options.trigger || "click";
  const element = options.element;
  element.addEventListener(trigger, function (evt) {
    if (!isValidTargetForEditableItemCallback(evt.target)) {
      return;
    }

    const win = this.ownerDocument.defaultView;
    const selection = win.getSelection();
    if (trigger != "click" || selection.isCollapsed) {
      callback(element, evt);
    }
    evt.stopPropagation();
  });

  // If focused by means other than a click, start editing by
  // pressing enter or space.
  element.addEventListener(
    "keypress",
    function (evt) {
      if (!isValidTargetForEditableItemCallback(evt.target)) {
        return;
      }

      if (isKeyIn(evt.keyCode, "RETURN") || isKeyIn(evt.charCode, "SPACE")) {
        callback(element);
      }
    },
    true
  );

  // Mark the element editable field for tab
  // navigation while editing.
  element._editable = true;
  // And an attribute that can be used to target
  element.setAttribute("editable", "");

  // Save the trigger type so we can dispatch this later
  element._trigger = trigger;

  // Add button semantics to the element, to indicate that it can be activated.
  element.setAttribute("role", "button");

  return function turnOnEditMode() {
    callback(element);
  };
}

exports.editableItem = editableItem;

/**
 * Returns false if the passed event target should not trigger the callback passed
 * to the editable item.
 *
 * @param {Element} eventTarget
 * @returns {boolean}
 */
function isValidTargetForEditableItemCallback(eventTarget) {
  const { nodeName } = eventTarget;
  // If the event happened on a link or a button, we shouldn't trigger the callback
  return nodeName !== "a" && nodeName !== "button";
}

/*
 * Various API consumers (especially tests) sometimes want to grab the
 * inplaceEditor expando off span elements. However, when each global has its
 * own compartment, those expandos live on Xray wrappers that are only visible
 * within this JSM. So we provide a little workaround here.
 */

function getInplaceEditorForSpan(span) {
  return span.inplaceEditor;
}

exports.getInplaceEditorForSpan = getInplaceEditorForSpan;

class InplaceEditor extends EventEmitter {
  constructor(options, event) {
    super();

    this.elt = options.element;
    const doc = this.elt.ownerDocument;
    this.doc = doc;
    this.elt.inplaceEditor = this;
    this.cssProperties = options.cssProperties;
    this.getCssVariables = options.getCssVariables
      ? options.getCssVariables.bind(this)
      : null;
    this.change = options.change;
    this.done = options.done;
    this.contextMenu = options.contextMenu;
    this.defaultIncrement = options.defaultIncrement || 1;
    this.destroy = options.destroy;
    this.initial = options.initial ? options.initial : this.elt.textContent;
    this.multiline = options.multiline || false;
    this.maxWidth = options.maxWidth;
    if (typeof this.maxWidth == "function") {
      this.maxWidth = this.maxWidth();
    }

    this.trimOutput =
      options.trimOutput === undefined ? true : !!options.trimOutput;
    this.stopOnShiftTab = !!options.stopOnShiftTab;
    this.stopOnTab = !!options.stopOnTab;
    this.stopOnReturn = !!options.stopOnReturn;
    this.contentType = options.contentType || CONTENT_TYPES.PLAIN_TEXT;
    this.property = options.property;
    this.popup = options.popup;
    this.preserveTextStyles =
      options.preserveTextStyles === undefined
        ? false
        : !!options.preserveTextStyles;
    this.showSuggestCompletionOnEmpty = !!options.showSuggestCompletionOnEmpty;
    this.focusEditableFieldAfterApply =
      options.focusEditableFieldAfterApply === true;
    this.focusEditableFieldContainerSelector =
      options.focusEditableFieldContainerSelector;

    if (
      this.focusEditableFieldAfterApply &&
      !this.focusEditableFieldContainerSelector
    ) {
      throw new Error(
        "focusEditableFieldContainerSelector is mandatory when focusEditableFieldAfterApply is true"
      );
    }

    this.#createInput(options);

    // Hide the provided element and add our editor.
    this.originalDisplay = this.elt.style.display;
    this.elt.style.display = "none";
    this.elt.parentNode.insertBefore(this.input, this.elt);

    // After inserting the input to have all CSS styles applied, start autosizing.
    this.#autosize();

    this.inputCharDimensions = this.#getInputCharDimensions();
    // Pull out character codes for advanceChars, listing the
    // characters that should trigger a blur.
    if (typeof options.advanceChars === "function") {
      this.#advanceChars = options.advanceChars;
    } else {
      const advanceCharcodes = {};
      const advanceChars = options.advanceChars || "";
      for (let i = 0; i < advanceChars.length; i++) {
        advanceCharcodes[advanceChars.charCodeAt(i)] = true;
      }
      this.#advanceChars = charCode => charCode in advanceCharcodes;
    }

    this.input.focus();

    if (typeof options.selectAll == "undefined" || options.selectAll) {
      this.input.select();
    }

    const win = doc.defaultView;
    this.#abortController = new win.AbortController();
    const eventListenerConfig = { signal: this.#abortController.signal };

    this.input.addEventListener("blur", this.#onBlur, eventListenerConfig);
    this.input.addEventListener(
      "keypress",
      this.#onKeyPress,
      eventListenerConfig
    );
    this.input.addEventListener("wheel", this.#onWheel, eventListenerConfig);
    this.input.addEventListener("input", this.#onInput, eventListenerConfig);
    this.input.addEventListener(
      "dblclick",
      this.#stopEventPropagation,
      eventListenerConfig
    );
    this.input.addEventListener(
      "click",
      this.#stopEventPropagation,
      eventListenerConfig
    );
    this.input.addEventListener(
      "mousedown",
      this.#stopEventPropagation,
      eventListenerConfig
    );
    this.input.addEventListener(
      "contextmenu",
      this.#onContextMenu,
      eventListenerConfig
    );
    win.addEventListener("blur", this.#onWindowBlur, eventListenerConfig);

    this.validate = options.validate;

    if (this.validate) {
      this.input.addEventListener("keyup", this.#onKeyup, eventListenerConfig);
    }

    this.#updateSize();

    if (options.start) {
      options.start(this, event);
    }

    this.#getGridNamesBeforeCompletion(options.getGridLineNames);
  }
  static CONTENT_TYPES = CONTENT_TYPES;

  #abortController;
  #advanceChars;
  #applied;
  #measurement;
  #openPopupTimeout;
  #pressedKey;
  #preventSuggestions;
  #selectedIndex;
  #variableNames;
  #variables;

  get currentInputValue() {
    const val = this.trimOutput ? this.input.value.trim() : this.input.value;
    return val;
  }

  /**
   * Create the input element.
   *
   * @param {object} options
   * @param {string} options.inputAriaLabel
   *        Optional aria-label attribute value that will be added to the input.
   * @param {string} options.inputAriaLabelledBy
   *        Optional aria-labelledby attribute value that will be added to the input.
   * @param {string} options.inputClass:
   *        Optional class to be added to the input.
   */
  #createInput(options = {}) {
    this.input = this.doc.createElementNS(
      HTML_NS,
      this.multiline ? "textarea" : "input"
    );
    this.input.inplaceEditor = this;

    if (this.multiline) {
      // Hide the textarea resize handle.
      this.input.style.resize = "none";
      this.input.style.overflow = "hidden";
      // Also reset padding.
      this.input.style.padding = "0";
    }

    this.input.classList.add("styleinspector-propertyeditor");
    if (options.inputClass) {
      this.input.classList.add(options.inputClass);
    }
    this.input.value = this.initial;
    if (options.inputAriaLabel) {
      this.input.setAttribute("aria-label", options.inputAriaLabel);
    } else if (options.inputAriaLabelledBy) {
      this.input.setAttribute("aria-labelledby", options.inputAriaLabelledBy);
    }

    if (!this.preserveTextStyles) {
      copyTextStyles(this.elt, this.input);
    }
  }

  /**
   * Get rid of the editor.
   *
   * @param {*|null} doneCallResult: When #clear is called after calling #apply, this will
   *        be the returned value of the call to options.done that is done there.
   *        Will be null when options.done is undefined.
   */
  #clear(doneCallResult) {
    if (!this.input) {
      // Already cleared.
      return;
    }

    this.#abortController.abort();
    this.#stopAutosize();

    this.elt.style.display = this.originalDisplay;

    if (this.doc.activeElement == this.input) {
      this.elt.focus();
    }

    this.input.remove();
    this.input = null;

    delete this.elt.inplaceEditor;
    delete this.elt;

    if (this.destroy) {
      this.destroy(doneCallResult);
    }
  }

  /**
   * Keeps the editor close to the size of its input string.  This is pretty
   * crappy, suggestions for improvement welcome.
   */
  #autosize() {
    // Create a hidden, absolutely-positioned span to measure the text
    // in the input.  Boo.

    // We can't just measure the original element because a) we don't
    // change the underlying element's text ourselves (we leave that
    // up to the client), and b) without tweaking the style of the
    // original element, it might wrap differently or something.
    this.#measurement = this.doc.createElementNS(
      HTML_NS,
      this.multiline ? "pre" : "span"
    );
    this.#measurement.className = "autosizer";
    this.elt.parentNode.appendChild(this.#measurement);
    const style = this.#measurement.style;
    style.visibility = "hidden";
    style.position = "absolute";
    style.top = "0";
    style.left = "0";

    if (this.multiline) {
      style.whiteSpace = "pre-wrap";
      style.wordWrap = "break-word";
      if (this.maxWidth) {
        style.maxWidth = this.maxWidth + "px";
        // Use position fixed to measure dimensions without any influence from
        // the container of the editor.
        style.position = "fixed";
      }
    }

    copyAllStyles(this.input, this.#measurement);
    this.#updateSize();
  }

  /**
   * Clean up the mess created by _autosize().
   */
  #stopAutosize() {
    if (!this.#measurement) {
      return;
    }
    this.#measurement.remove();
    this.#measurement = null;
  }

  /**
   * Size the editor to fit its current contents.
   */
  #updateSize() {
    // Replace spaces with non-breaking spaces.  Otherwise setting
    // the span's textContent will collapse spaces and the measurement
    // will be wrong.
    let content = this.input.value;
    const unbreakableSpace = "\u00a0";

    // Make sure the content is not empty.
    if (content === "") {
      content = unbreakableSpace;
    }

    // If content ends with a new line, add a blank space to force the autosize
    // element to adapt its height.
    if (content.lastIndexOf("\n") === content.length - 1) {
      content = content + unbreakableSpace;
    }

    if (!this.multiline) {
      content = content.replace(/ /g, unbreakableSpace);
    }

    this.#measurement.textContent = content;

    // Do not use offsetWidth: it will round floating width values.
    let width = this.#measurement.getBoundingClientRect().width;
    if (this.multiline) {
      if (this.maxWidth) {
        width = Math.min(this.maxWidth, width);
      }
      const height = this.#measurement.getBoundingClientRect().height;
      this.input.style.height = height + "px";
    }
    this.input.style.width = width + "px";
  }

  /**
   * Get the width and height of a single character in the input to properly
   * position the autocompletion popup.
   */
  #getInputCharDimensions() {
    // Just make the text content to be 'x' to get the width and height of any
    // character in a monospace font.
    this.#measurement.textContent = "x";
    const width = this.#measurement.clientWidth;
    const height = this.#measurement.clientHeight;
    return { width, height };
  }

  /**
   * Increment property values in rule view.
   *
   * @param {number} increment
   *        The amount to increase/decrease the property value.
   * @return {boolean} true if value has been incremented.
   */
  #incrementValue(increment) {
    const value = this.input.value;
    const selectionStart = this.input.selectionStart;
    const selectionEnd = this.input.selectionEnd;

    const newValue = this.#incrementCSSValue(
      value,
      increment,
      selectionStart,
      selectionEnd
    );

    if (!newValue) {
      return false;
    }

    this.input.value = newValue.value;
    this.input.setSelectionRange(newValue.start, newValue.end);
    this.#doValidation();

    // Call the user's change handler if available.
    if (this.change) {
      this.change(this.currentInputValue);
    }

    return true;
  }

  /**
   * Increment the property value based on the property type.
   *
   * @param {string} value
   *        Property value.
   * @param {number} increment
   *        Amount to increase/decrease the property value.
   * @param {number} selStart
   *        Starting index of the value.
   * @param {number} selEnd
   *        Ending index of the value.
   * @return {object} object with properties 'value', 'start', and 'end'.
   */
  #incrementCSSValue(value, increment, selStart, selEnd) {
    const range = this.#parseCSSValue(value, selStart);
    const type = range?.type || "";
    const rawValue = range ? value.substring(range.start, range.end) : "";
    const preRawValue = range ? value.substr(0, range.start) : "";
    const postRawValue = range ? value.substr(range.end) : "";
    let info;

    let incrementedValue = null,
      selection;
    if (type === "num") {
      if (rawValue == "0") {
        info = {};
        info.units = this.#findCompatibleUnit(preRawValue, postRawValue);
      }

      const newValue = this.#incrementRawValue(rawValue, increment, info);
      if (newValue !== null) {
        incrementedValue = newValue;
        selection = [0, incrementedValue.length];
      }
    } else if (type === "hex") {
      const exprOffset = selStart - range.start;
      const exprOffsetEnd = selEnd - range.start;
      const newValue = this.#incHexColor(
        rawValue,
        increment,
        exprOffset,
        exprOffsetEnd
      );
      if (newValue) {
        incrementedValue = newValue.value;
        selection = newValue.selection;
      }
    } else {
      if (type === "rgb" || type === "hsl" || type === "hwb") {
        info = {};
        const isCSS4Color = !value.includes(",");
        // In case the value uses the new syntax of the CSS Color 4 specification,
        // it is split by the spaces and the slash separating the alpha value
        // between the different color components.
        // Example: rgb(255 0 0 / 0.5)
        // Otherwise, the value is represented using the old color syntax and is
        // split by the commas between the color components.
        // Example: rgba(255, 0, 0, 0.5)
        const part =
          value
            .substring(range.start, selStart)
            .split(isCSS4Color ? / ?\/ ?| / : ",").length - 1;
        if (part === 3) {
          // alpha
          info.minValue = 0;
          info.maxValue = 1;
        } else if (type === "rgb") {
          info.minValue = 0;
          info.maxValue = 255;
        } else if (part !== 0) {
          // hsl or hwb percentage
          info.minValue = 0;
          info.maxValue = 100;

          // select the previous number if the selection is at the end of a
          // percentage sign.
          if (value.charAt(selStart - 1) === "%") {
            --selStart;
          }
        }
      }
      return this.#incrementGenericValue(
        value,
        increment,
        selStart,
        selEnd,
        info
      );
    }

    if (incrementedValue === null) {
      return null;
    }

    return {
      value: preRawValue + incrementedValue + postRawValue,
      start: range.start + selection[0],
      end: range.start + selection[1],
    };
  }

  /**
   * Find a compatible unit to use for a CSS number value inserted between the
   * provided beforeValue and afterValue. The compatible unit will be picked
   * from a selection of default units corresponding to supported CSS value
   * dimensions (distance, angle, duration).
   *
   * @param {string} beforeValue
   *        The string preceeding the number value in the current property
   *        value.
   * @param {string} afterValue
   *        The string following the number value in the current property value.
   * @return {string} a valid unit that can be used for this number value or
   *         empty string if no match could be found.
   */
  #findCompatibleUnit(beforeValue, afterValue) {
    if (!this.property || !this.property.name) {
      return "";
    }

    // A DOM element is used to test the validity of various units. This is to
    // avoid having to do an async call to the server to get this information.
    const el = this.doc.createElement("div");

    // Cycle through unitless (""), pixels, degrees and seconds.
    const units = ["", "px", "deg", "s"];
    for (const unit of units) {
      const value = beforeValue + "1" + unit + afterValue;
      el.style.setProperty(this.property.name, "");
      el.style.setProperty(this.property.name, value);
      // The property was set to `""` first, so if the value is no longer `""`,
      // it means that the second `setProperty` call set a valid property and we
      // can use this unit.
      if (el.style.getPropertyValue(this.property.name) !== "") {
        return unit;
      }
    }
    return "";
  }

  /**
   * Parses the property value and type.
   *
   * @param {string} value
   *        Property value.
   * @param {number} offset
   *        Starting index of value.
   * @return {object} object with properties 'value', 'start', 'end', and
   *         'type'.
   */
  #parseCSSValue(value, offset) {
    /* eslint-disable max-len */
    const reSplitCSS =
      /(?<url>url\("?[^"\)]+"?\)?)|(?<rgb>rgba?\([^)]*\)?)|(?<hsl>hsla?\([^)]*\)?)|(?<hwb>hwb\([^)]*\)?)|(?<hex>#[\dA-Fa-f]+)|(?<number>-?\d*\.?\d+(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/;
    /* eslint-enable */
    let start = 0;
    let m;

    // retreive values from left to right until we find the one at our offset
    while ((m = reSplitCSS.exec(value)) && m.index + m[0].length < offset) {
      value = value.substring(m.index + m[0].length);
      start += m.index + m[0].length;
      offset -= m.index + m[0].length;
    }

    if (!m) {
      return null;
    }

    let type;
    if (m.groups.url) {
      type = "url";
    } else if (m.groups.rgb) {
      type = "rgb";
    } else if (m.groups.hsl) {
      type = "hsl";
    } else if (m.groups.hwb) {
      type = "hwb";
    } else if (m.groups.hex) {
      type = "hex";
    } else if (m.groups.number) {
      type = "num";
    }

    return {
      value: m[0],
      start: start + m.index,
      end: start + m.index + m[0].length,
      type,
    };
  }

  /**
   * Increment the property value for types other than
   * number or hex, such as rgb, hsl, hwb, and file names.
   *
   * @param {string} value
   *        Property value.
   * @param {number} increment
   *        Amount to increment/decrement.
   * @param {number} offset
   *        Starting index of the property value.
   * @param {number} offsetEnd
   *        Ending index of the property value.
   * @param {object} info
   *        Object with details about the property value.
   * @return {object} object with properties 'value', 'start', and 'end'.
   */
  #incrementGenericValue(value, increment, offset, offsetEnd, info) {
    // Try to find a number around the cursor to increment.
    let start, end;
    // Check if we are incrementing in a non-number context (such as a URL)
    if (
      /^-?[0-9.]/.test(value.substring(offset, offsetEnd)) &&
      !/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd))
    ) {
      // We have a number selected, possibly with a suffix, and we are not in
      // the disallowed case of just part of a known number being selected.
      // Use that number.
      start = offset;
      end = offsetEnd;
    } else {
      // Parse periods as belonging to the number only if we are in a known
      // number context. (This makes incrementing the 1 in 'image1.gif' work.)
      const pattern = "[" + (info ? "0-9." : "0-9") + "]*";
      const before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0]
        .length;
      const after = new RegExp("^" + pattern).exec(value.substr(offset))[0]
        .length;

      start = offset - before;
      end = offset + after;

      // Expand the number to contain an initial minus sign if it seems
      // free-standing.
      if (
        value.charAt(start - 1) === "-" &&
        (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2)))
      ) {
        --start;
      }
    }

    if (start !== end) {
      // Include percentages as part of the incremented number (they are
      // common enough).
      if (value.charAt(end) === "%") {
        ++end;
      }

      const first = value.substr(0, start);
      let mid = value.substring(start, end);
      const last = value.substr(end);

      mid = this.#incrementRawValue(mid, increment, info);

      if (mid !== null) {
        return {
          value: first + mid + last,
          start,
          end: start + mid.length,
        };
      }
    }

    return null;
  }

  /**
   * Increment the property value for numbers.
   *
   * @param {string} rawValue
   *        Raw value to increment.
   * @param {number} increment
   *        Amount to increase/decrease the raw value.
   * @param {object} info
   *        Object with info about the property value.
   * @return {string} the incremented value.
   */
  #incrementRawValue(rawValue, increment, info) {
    const num = parseFloat(rawValue);

    if (isNaN(num)) {
      return null;
    }

    const number = /\d+(\.\d+)?/.exec(rawValue);

    let units = rawValue.substr(number.index + number[0].length);
    if (info && "units" in info) {
      units = info.units;
    }

    // avoid rounding errors
    let newValue = Math.round((num + increment) * 1000) / 1000;

    if (info && "minValue" in info) {
      newValue = Math.max(newValue, info.minValue);
    }
    if (info && "maxValue" in info) {
      newValue = Math.min(newValue, info.maxValue);
    }

    newValue = newValue.toString();

    return newValue + units;
  }

  /**
   * Increment the property value for hex.
   *
   * @param {string} value
   *        Property value.
   * @param {number} increment
   *        Amount to increase/decrease the property value.
   * @param {number} offset
   *        Starting index of the property value.
   * @param {number} offsetEnd
   *        Ending index of the property value.
   * @return {object} object with properties 'value' and 'selection'.
   */
  #incHexColor(rawValue, increment, offset, offsetEnd) {
    // Return early if no part of the rawValue is selected.
    if (offsetEnd > rawValue.length && offset >= rawValue.length) {
      return null;
    }
    if (offset < 1 && offsetEnd <= 1) {
      return null;
    }
    // Ignore the leading #.
    rawValue = rawValue.substr(1);
    --offset;
    --offsetEnd;

    // Clamp the selection to within the actual value.
    offset = Math.max(offset, 0);
    offsetEnd = Math.min(offsetEnd, rawValue.length);
    offsetEnd = Math.max(offsetEnd, offset);

    // Normalize #ABC -> #AABBCC.
    if (rawValue.length === 3) {
      rawValue =
        rawValue.charAt(0) +
        rawValue.charAt(0) +
        rawValue.charAt(1) +
        rawValue.charAt(1) +
        rawValue.charAt(2) +
        rawValue.charAt(2);
      offset *= 2;
      offsetEnd *= 2;
    }

    // Normalize #ABCD -> #AABBCCDD.
    if (rawValue.length === 4) {
      rawValue =
        rawValue.charAt(0) +
        rawValue.charAt(0) +
        rawValue.charAt(1) +
        rawValue.charAt(1) +
        rawValue.charAt(2) +
        rawValue.charAt(2) +
        rawValue.charAt(3) +
        rawValue.charAt(3);
      offset *= 2;
      offsetEnd *= 2;
    }

    if (rawValue.length !== 6 && rawValue.length !== 8) {
      return null;
    }

    // If no selection, increment an adjacent color, preferably one to the left.
    if (offset === offsetEnd) {
      if (offset === 0) {
        offsetEnd = 1;
      } else {
        offset = offsetEnd - 1;
      }
    }

    // Make the selection cover entire parts.
    offset -= offset % 2;
    offsetEnd += offsetEnd % 2;

    // Remap the increments from [0.1, 1, 10] to [1, 1, 16].
    if (increment > -1 && increment < 1) {
      increment = increment < 0 ? -1 : 1;
    }
    if (Math.abs(increment) === 10) {
      increment = increment < 0 ? -16 : 16;
    }

    const isUpper = rawValue.toUpperCase() === rawValue;

    for (let pos = offset; pos < offsetEnd; pos += 2) {
      // Increment the part in [pos, pos+2).
      let mid = rawValue.substr(pos, 2);
      const value = parseInt(mid, 16);

      if (isNaN(value)) {
        return null;
      }

      mid = Math.min(Math.max(value + increment, 0), 255).toString(16);

      while (mid.length < 2) {
        mid = "0" + mid;
      }
      if (isUpper) {
        mid = mid.toUpperCase();
      }

      rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2);
    }

    return {
      value: "#" + rawValue,
      selection: [offset + 1, offsetEnd + 1],
    };
  }

  /**
   * Cycle through the autocompletion suggestions in the popup.
   *
   * @param {boolean} reverse
   *        true to select previous item from the popup.
   * @param {boolean} noSelect
   *        true to not select the text after selecting the newly selectedItem
   *        from the popup.
   */
  #cycleCSSSuggestion(reverse, noSelect) {
    // selectedItem can be null when nothing is selected in an empty editor.
    const { label, preLabel } = this.popup.selectedItem || {
      label: "",
      preLabel: "",
    };
    if (reverse) {
      this.popup.selectPreviousItem();
    } else {
      this.popup.selectNextItem();
    }

    this.#selectedIndex = this.popup.selectedIndex;
    const input = this.input;
    let pre = "";

    if (input.selectionStart < input.selectionEnd) {
      pre = input.value.slice(0, input.selectionStart);
    } else {
      pre = input.value.slice(
        0,
        input.selectionStart - label.length + preLabel.length
      );
    }

    const post = input.value.slice(input.selectionEnd, input.value.length);
    const item = this.popup.selectedItem;
    const toComplete = item.label.slice(item.preLabel.length);
    input.value = pre + toComplete + post;

    if (!noSelect) {
      input.setSelectionRange(pre.length, pre.length + toComplete.length);
    } else {
      input.setSelectionRange(
        pre.length + toComplete.length,
        pre.length + toComplete.length
      );
    }

    this.#updateSize();
    // This emit is mainly for the purpose of making the test flow simpler.
    this.emit("after-suggest");
  }

  /**
   * Call the client's done handler and clear out.
   */
  #apply(direction, key) {
    if (this.#applied) {
      return null;
    }

    this.#applied = true;

    if (this.done) {
      const val = this.cancelled ? this.initial : this.currentInputValue;
      return this.done(val, !this.cancelled, direction, key);
    }

    return null;
  }

  /**
   * Hide the popup and cancel any pending popup opening.
   */
  #onWindowBlur = () => {
    if (this.popup && this.popup.isOpen) {
      this.popup.hidePopup();
    }

    if (this.#openPopupTimeout) {
      this.doc.defaultView.clearTimeout(this.#openPopupTimeout);
    }
  };

  /**
   * Event handler called when the inplace-editor's input loses focus.
   */
  #onBlur = event => {
    if (
      event &&
      this.popup &&
      this.popup.isOpen &&
      this.popup.selectedIndex >= 0
    ) {
      this.#acceptPopupSuggestion();
    } else {
      const onApplied = this.#apply();
      this.#clear(onApplied);
    }
  };

  /**
   * Before offering autocomplete, set this.gridLineNames as the line names
   * of the current grid, if they exist.
   *
   * @param {Function} getGridLineNames
   *        A function which gets the line names of the current grid.
   */
  async #getGridNamesBeforeCompletion(getGridLineNames) {
    if (
      getGridLineNames &&
      this.property &&
      GRID_PROPERTY_NAMES.includes(this.property.name)
    ) {
      this.gridLineNames = await getGridLineNames();
    }

    if (
      this.contentType == CONTENT_TYPES.CSS_VALUE &&
      this.input &&
      this.input.value == ""
    ) {
      this.#maybeSuggestCompletion(false);
    }
  }

  /**
   * Event handler called by the autocomplete popup when receiving a click
   * event.
   */
  #onAutocompletePopupClick = () => {
    this.#acceptPopupSuggestion();
  };

  #acceptPopupSuggestion() {
    let label, preLabel;

    if (this.#selectedIndex === undefined) {
      ({ label, preLabel } = this.popup.getItemAtIndex(
        this.popup.selectedIndex
      ));
    } else {
      ({ label, preLabel } = this.popup.getItemAtIndex(this.#selectedIndex));
    }

    const input = this.input;

    let pre = "";

    // CSS_MIXED needs special treatment here to make it so that
    // multiple presses of tab will cycle through completions, but
    // without selecting the completed text.  However, this same
    // special treatment will do the wrong thing for other editing
    // styles.
    if (
      input.selectionStart < input.selectionEnd ||
      this.contentType !== CONTENT_TYPES.CSS_MIXED
    ) {
      pre = input.value.slice(0, input.selectionStart);
    } else {
      pre = input.value.slice(
        0,
        input.selectionStart - label.length + preLabel.length
      );
    }
    const post = input.value.slice(input.selectionEnd, input.value.length);
    const item = this.popup.selectedItem;
    this.#selectedIndex = this.popup.selectedIndex;
    const toComplete = item.label.slice(item.preLabel.length);
    input.value = pre + toComplete + post;
    input.setSelectionRange(
      pre.length + toComplete.length,
      pre.length + toComplete.length
    );
    this.#updateSize();
    // Wait for the popup to hide and then focus input async otherwise it does
    // not work.
    const onPopupHidden = () => {
      this.popup.off("popup-closed", onPopupHidden);
      this.doc.defaultView.setTimeout(() => {
        input.focus();
        this.emit("after-suggest");
      }, 0);
    };
    this.popup.on("popup-closed", onPopupHidden);
    this.#hideAutocompletePopup();
  }

  /**
   * Handle the input field's keypress event.
   */
  // eslint-disable-next-line complexity
  #onKeyPress = event => {
    let prevent = false;

    const key = event.keyCode;
    const input = this.input;

    // We want to autoclose some characters, remember the pressed key in order to process
    // it later on in maybeSuggestionCompletion().
    this.#pressedKey = event.key;

    const multilineNavigation =
      !this.#isSingleLine() && isKeyIn(key, "UP", "DOWN", "LEFT", "RIGHT");
    const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT;
    const isPopupOpen = this.popup && this.popup.isOpen;

    let increment = 0;
    if (!isPlainText && !multilineNavigation) {
      increment = this.#getIncrement(event);
    }

    if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
      this.#preventSuggestions = true;
    }

    let cycling = false;
    if (increment && this.#incrementValue(increment)) {
      this.#updateSize();
      prevent = true;
      cycling = true;
    }

    if (isPopupOpen && isKeyIn(key, "UP", "DOWN", "PAGE_UP", "PAGE_DOWN")) {
      prevent = true;
      cycling = true;
      this.#cycleCSSSuggestion(isKeyIn(key, "UP", "PAGE_UP"));
      this.#doValidation();
    }

    if (isKeyIn(key, "BACK_SPACE", "DELETE", "LEFT", "RIGHT", "HOME", "END")) {
      if (isPopupOpen && this.currentInputValue !== "") {
        this.#hideAutocompletePopup();
      }
    } else if (
      // We may show the suggestion completion if Ctrl+space is pressed, or if an
      // otherwise unhandled key is pressed and the user is not cycling through the
      // options in the pop-up menu, it is not an expanded shorthand property, no
      // modifier key is pressed, and the pressed key isn't Shift+Arrow(Up|Down).
      (event.key === " " && event.ctrlKey) ||
      (!cycling &&
        !multilineNavigation &&
        !event.metaKey &&
        !event.altKey &&
        !event.ctrlKey &&
        // We only need to handle the case where the Shift key is pressed because maybeSuggestCompletion
        // will trigger the completion because there are selected character here, and it
        // will look like a "regular" completion with a suggested value. We don't need
        // to care about other shift + key (e.g. LEFT, HOME, …), since we're not coming
        // here for them.
        !(isKeyIn(key, "UP", "DOWN") && event.shiftKey))
    ) {
      this.#maybeSuggestCompletion(true);
    }

    if (this.multiline && event.shiftKey && isKeyIn(key, "RETURN")) {
      prevent = false;
    } else if (
      this.#advanceChars(event.charCode, input.value, input.selectionStart) ||
      isKeyIn(key, "RETURN", "TAB")
    ) {
      prevent = true;

      const ctrlOrCmd = isOSX ? event.metaKey : event.ctrlKey;

      let direction;
      if (
        (this.stopOnReturn && isKeyIn(key, "RETURN") && !ctrlOrCmd) ||
        (this.stopOnTab && !event.shiftKey && isKeyIn(key, "TAB")) ||
        (this.stopOnShiftTab && event.shiftKey && isKeyIn(key, "TAB"))
      ) {
        direction = null;
      } else if (event.shiftKey && isKeyIn(key, "TAB")) {
        direction = FOCUS_BACKWARD;
      } else {
        direction = FOCUS_FORWARD;
      }

      // Now we don't want to suggest anything as we are moving out.
      this.#preventSuggestions = true;
      // But we still want to show suggestions for css values. i.e. moving out
      // of css property input box in forward direction
      if (
        this.contentType == CONTENT_TYPES.CSS_PROPERTY &&
        direction == FOCUS_FORWARD
      ) {
        this.#preventSuggestions = false;
      }

      if (isKeyIn(key, "TAB") && this.contentType == CONTENT_TYPES.CSS_MIXED) {
        if (this.popup && input.selectionStart < input.selectionEnd) {
          event.preventDefault();
          input.setSelectionRange(input.selectionEnd, input.selectionEnd);
          this.emit("after-suggest");
          return;
        } else if (this.popup && this.popup.isOpen) {
          event.preventDefault();
          this.#cycleCSSSuggestion(event.shiftKey, true);
          return;
        }
      }

      const onApplied = this.#apply(direction, key);

      // Close the popup if open
      if (this.popup && this.popup.isOpen) {
        this.#hideAutocompletePopup();
      }

      if (direction !== null && focusManager.focusedElement === input) {
        // If the focused element wasn't changed by the done callback,
        // move the focus as requested.
        const next = moveFocus(
          this.doc.defaultView,
          direction,
          this.focusEditableFieldAfterApply,
          this.focusEditableFieldContainerSelector
        );

        // If the next node to be focused has been tagged as an editable
        // node, trigger editing using the configured event
        if (next && next.ownerDocument === this.doc && next._editable) {
          const e = this.doc.createEvent("Event");
          e.initEvent(next._trigger, true, true);
          next.dispatchEvent(e);
        }
      }

      this.#clear(onApplied);
    } else if (isKeyIn(key, "ESCAPE")) {
      // Cancel and blur ourselves.
      // Now we don't want to suggest anything as we are moving out.
      this.#preventSuggestions = true;
      // Close the popup if open
      if (this.popup && this.popup.isOpen) {
        this.#hideAutocompletePopup();
      } else {
        this.cancelled = true;
        const onApplied = this.#apply();
        this.#clear(onApplied);
      }
      prevent = true;
      event.stopPropagation();
    } else if (isKeyIn(key, "SPACE")) {
      // No need for leading spaces here.  This is particularly
      // noticable when adding a property: it's very natural to type
      // <name>: (which advances to the next property) then spacebar.
      prevent = !input.value;
    }

    if (prevent) {
      event.preventDefault();
    }
  };

  #onContextMenu = event => {
    if (this.contextMenu) {
      // Call stopPropagation() and preventDefault() here so that avoid to show default
      // context menu in about:devtools-toolbox. See Bug 1515265.
      event.stopPropagation();
      event.preventDefault();
      this.contextMenu(event);
    }
  };

  /**
   * Open the autocomplete popup, adding a custom click handler and classname.
   *
   * @param {number} offset
   *        X-offset relative to the input starting edge.
   * @param {number} selectedIndex
   *        The index of the item that should be selected. Use -1 to have no
   *        item selected.
   */
  #openAutocompletePopup(offset, selectedIndex) {
    this.popup.on("popup-click", this.#onAutocompletePopupClick);
    this.popup.openPopup(this.input, offset, 0, selectedIndex);
  }

  /**
   * Remove the custom classname and click handler and close the autocomplete
   * popup.
   */
  #hideAutocompletePopup() {
    this.popup.off("popup-click", this.#onAutocompletePopupClick);
    this.popup.hidePopup();
  }

  /**
   * Get the increment/decrement step to use for the provided key or wheel
   * event.
   *
   * @param {Event} event
   *        The event from which the increment should be comuted
   * @return {number} The computed increment value.
   */
  #getIncrement(event) {
    const largeIncrement = 100;
    const mediumIncrement = 10;
    const smallIncrement = 0.1;

    let increment = 0;

    let wheelUp = false;
    let wheelDown = false;
    if (event.type === "wheel") {
      if (event.wheelDelta > 0) {
        wheelUp = true;
      } else if (event.wheelDelta < 0) {
        wheelDown = true;
      }
    }

    const key = event.keyCode;

    if (wheelUp || isKeyIn(key, "UP", "PAGE_UP")) {
      increment = 1 * this.defaultIncrement;
    } else if (wheelDown || isKeyIn(key, "DOWN", "PAGE_DOWN")) {
      increment = -1 * this.defaultIncrement;
    }

    const largeIncrementKeyPressed = event.shiftKey;
    const smallIncrementKeyPressed = this.#isSmallIncrementKeyPressed(event);
    if (largeIncrementKeyPressed && !smallIncrementKeyPressed) {
      if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
        increment *= largeIncrement;
      } else {
        increment *= mediumIncrement;
      }
    } else if (smallIncrementKeyPressed && !largeIncrementKeyPressed) {
      increment *= smallIncrement;
    }

    return increment;
  }

  #isSmallIncrementKeyPressed = evt => {
    if (isOSX) {
      return evt.altKey;
    }
    return evt.ctrlKey;
  };

  /**
   * Handle the input field's keyup event.
   */
  #onKeyup = () => {
    this.#applied = false;
  };

  /**
   * Handle changes to the input text.
   */
  #onInput = () => {
    // Validate the entered value.
    this.#doValidation();

    // Update size if we're autosizing.
    if (this.#measurement) {
      this.#updateSize();
    }

    // Call the user's change handler if available.
    if (this.change) {
      this.change(this.currentInputValue);
    }

    // In case that the current value becomes empty, show the suggestions if needed.
    if (this.currentInputValue === "" && this.showSuggestCompletionOnEmpty) {
      this.#maybeSuggestCompletion(false);
    }
  };

  /**
   * Handle the input field's wheel event.
   *
   * @param {WheelEvent} event
   */
  #onWheel = event => {
    const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT;
    let increment = 0;
    if (!isPlainText) {
      increment = this.#getIncrement(event);
    }

    if (increment && this.#incrementValue(increment)) {
      this.#updateSize();
      event.preventDefault();
    }
  };

  /**
   * Stop propagation on the provided event
   */
  #stopEventPropagation(e) {
    e.stopPropagation();
  }

  /**
   * Fire validation callback with current input
   */
  #doValidation() {
    if (this.validate && this.input) {
      this.validate(this.input.value);
    }
  }

  /**
   * Handles displaying suggestions based on the current input.
   *
   * @param {boolean} autoInsert
   *        Pass true to automatically insert the most relevant suggestion.
   */
  #maybeSuggestCompletion(autoInsert) {
    // Input can be null in cases when you intantaneously switch out of it.
    if (!this.input) {
      return;
    }

    const preTimeoutQuery = this.input.value;

    // Since we are calling this method from a keypress event handler, the
    // |input.value| does not include currently typed character. Thus we perform
    // this method async.
    // eslint-disable-next-line complexity
    this.#openPopupTimeout = this.doc.defaultView.setTimeout(() => {
      if (this.#preventSuggestions) {
        this.#preventSuggestions = false;
        return;
      }
      if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) {
        return;
      }
      if (!this.input) {
        return;
      }
      const input = this.input;
      // The length of input.value should be increased by 1
      if (input.value.length - preTimeoutQuery.length > 1) {
        return;
      }
      const query = input.value.slice(0, input.selectionStart);
      let startCheckQuery = query;
      if (query == null) {
        return;
      }
      // If nothing is selected and there is a word (\w) character after the cursor, do
      // not autocomplete.
      if (
        input.selectionStart == input.selectionEnd &&
        input.selectionStart < input.value.length
      ) {
        const nextChar = input.value.slice(input.selectionStart)[0];
        // Check if the next character is a valid word character, no suggestion should be
        // provided when preceeding a word.
        if (/[\w-]/.test(nextChar)) {
          // This emit is mainly to make the test flow simpler.
          this.emit("after-suggest", "nothing to autocomplete");
          return;
        }
      }

      let list = [];
      let postLabelValues = [];

      if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) {
        list = this.#getCSSVariableNames().concat(this.#getCSSPropertyList());
      } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) {
        // Build the context for the autocomplete
        // TODO: We may want to parse the whole input, or at least, until we get into
        // an empty state (e.g. if cursor is in a function, we might check what's after
        // the cursor to build good autocomplete).
        const lexer = new InspectorCSSParserWrapper(query);
        const functionStack = [];
        let token;
        // The last parsed token that isn't a whitespace or a comment
        let lastMeaningfulToken;
        let foundImportant = false;
        let importantState = "";

        let queryStartIndex = 0;
        while ((token = lexer.nextToken())) {
          const currentFunction = functionStack.at(-1);
          if (
            token.tokenType !== "WhiteSpace" &&
            token.tokenType !== "Comment"
          ) {
            lastMeaningfulToken = token;
            if (currentFunction) {
              currentFunction.tokens.push(token);
            }
          }
          if (
            token.tokenType === "Function" ||
            token.tokenType === "ParenthesisBlock"
          ) {
            functionStack.push({ fnToken: token, tokens: [] });
          } else if (token.tokenType === "CloseParenthesis") {
            functionStack.pop();
          }

          if (
            token.tokenType === "WhiteSpace" ||
            token.tokenType === "Comma" ||
            token.tokenType === "Function" ||
            (token.tokenType === "Comment" &&
              // The parser already returns a comment token for non-closed comment, like "/*".
              // But we only want to start the completion after the comment is closed
              // Make sure we have a closed comment,i.e. at least `/**/`
              token.text.length >= 4 &&
              token.text.endsWith("*/"))
          ) {
            queryStartIndex = token.endOffset;
          }

          // Checking for the presence of !important (once is enough)
          if (!foundImportant) {
            // !important is composed of 2 tokens, `!` is a Delim, and `important` is an Ident.
            // Here we have a potential start
            if (token.tokenType === "Delim" && token.text === "!") {
              importantState = "!";
            } else if (importantState === "!") {
              // If we saw the "!" char, then we need to have an "important" Ident
              if (token.tokenType === "Ident" && token.text === "important") {
                foundImportant = true;
                break;
              } else {
                // otherwise, we can reset the state.
                importantState = "";
              }
            }
          }
        }

        startCheckQuery = query.substring(queryStartIndex);

        const lastFunctionEntry = functionStack.at(-1);
        const functionValues = lastFunctionEntry
          ? this.#getAutocompleteDataForFunction(lastFunctionEntry)
          : null;

        // Don't autocomplete after !important
        if (foundImportant) {
          list = [];
          postLabelValues = [];
        } else if (functionValues) {
          list = functionValues.list;
          postLabelValues = functionValues.postLabelValues;
        } else {
          list = this.#getCSSValuesForPropertyName(this.property.name);
          // Only show !important if:
          if (
            // we're not in a function
            !functionStack.length &&
            // and there is no non-whitespace items after the cursor
            !input.value.slice(input.selectionStart).trim() &&
            // and the last meaningful token wasn't a delimiter or a comma
            lastMeaningfulToken &&
            (lastMeaningfulToken.tokenType !== "Delim" ||
              lastMeaningfulToken.text !== "/") &&
            lastMeaningfulToken.tokenType !== "Comma" &&
            // and the input value doesn't start with ! ("!important" is parsed as a
            // Delim, "!", and then an indent, "important", so we can't just check the
            // last token)
            !input.value.trim().startsWith("!")
          ) {
            list.unshift("!important");
          }
        }
      } else if (
        this.contentType == CONTENT_TYPES.CSS_MIXED &&
        /^\s*style\s*=/.test(query)
      ) {
        // Check if the style attribute is closed before the selection.
        const styleValue = query.replace(/^\s*style\s*=\s*/, "");
        // Look for a quote matching the opening quote (single or double).
        if (/^("[^"]*"|'[^']*')/.test(styleValue)) {
          // This emit is mainly to make the test flow simpler.
          this.emit("after-suggest", "nothing to autocomplete");
          return;
        }

        // Detecting if cursor is at property or value;
        const match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/);
        if (match && match.length >= 2) {
          if (match[1] == ":") {
            // We are in CSS value completion
            const propertyName = query.match(
              /[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]*$/
            )[1];
            list = [
              "!important;",
              ...this.#getCSSValuesForPropertyName(propertyName),
            ];
            const matchLastQuery = /([^\s,.\/]+$)/.exec(match[2] || "");
            if (matchLastQuery) {
              startCheckQuery = matchLastQuery[0];
            } else {
              startCheckQuery = "";
            }
            if (!match[2]) {
              // Don't suggest '!important' without any manually typed character
              list.splice(0, 1);
            }
          } else if (match[1]) {
            // We are in CSS property name completion
            list = this.#getCSSVariableNames().concat(
              this.#getCSSPropertyList()
            );
            startCheckQuery = match[2];
          }
          if (startCheckQuery == null) {
            // This emit is mainly to make the test flow simpler.
            this.emit("after-suggest", "nothing to autocomplete");
            return;
          }
        }
      }

      if (!this.popup) {
        // This emit is mainly to make the test flow simpler.
        this.emit("after-suggest", "no popup");
        return;
      }

      const finalList = [];
      const length = list.length;
      for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) {
        if (startCheckQuery != null && list[i].startsWith(startCheckQuery)) {
          count++;
          finalList.push({
            preLabel: startCheckQuery,
            label: list[i],
            postLabel: postLabelValues[i] ? postLabelValues[i] : "",
          });
        } else if (count > 0) {
          // Since count was incremented, we had already crossed the entries
          // which would have started with query, assuming that list is sorted.
          break;
        } else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) {
          // We have crossed all possible matches alphabetically.
          break;
        }
      }

      // Sort items starting with [a-z0-9] first, to make sure vendor-prefixed
      // values and "!important" are suggested only after standard values.
      finalList.sort((item1, item2) => {
        // Get the expected alphabetical comparison between the items.
        let comparison = item1.label.localeCompare(item2.label);
        if (/^\w/.test(item1.label) != /^\w/.test(item2.label)) {
          // One starts with [a-z0-9], one does not: flip the comparison.
          comparison = -1 * comparison;
        }
        return comparison;
      });

      let index = 0;
      if (startCheckQuery) {
        // Only select a "best" suggestion when the user started a query.
        const cssValues = finalList.map(item => item.label);
        index = findMostRelevantCssPropertyIndex(cssValues);
      }

      // Insert the most relevant item from the final list as the input value.
      if (autoInsert && finalList[index]) {
        const item = finalList[index].label;
        input.value =
          query +
          item.slice(startCheckQuery.length) +
          input.value.slice(query.length);
        input.setSelectionRange(
          query.length,
          query.length + item.length - startCheckQuery.length
        );
        this.#updateSize();
      }

      // Display the list of suggestions if there are more than one.
      if (finalList.length > 1) {
        // Calculate the popup horizontal offset.
        const indent = this.input.selectionStart - startCheckQuery.length;
        let offset = indent * this.inputCharDimensions.width;
        offset = this.#isSingleLine() ? offset : 0;

        // Select the most relevantItem if autoInsert is allowed
        const selectedIndex = autoInsert ? index : -1;

        // Open the suggestions popup.
        this.popup.setItems(finalList, selectedIndex);
        this.#openAutocompletePopup(offset, selectedIndex);
      } else {
        this.#hideAutocompletePopup();
      }

      this.#autocloseParenthesis();

      // This emit is mainly for the purpose of making the test flow simpler.
      this.emit("after-suggest");
      this.#doValidation();
    }, 0);
  }

  /**
   * Returns the autocomplete data for the passed function.
   *
   * @param {object} functionStackEntry
   * @param {InspectorCSSToken} functionStackEntry.fnToken: The token for the
   *        function call
   * @returns {object | null} Return null if there's nothing specific to display for the function.
   *          Otherwise, return an object of the following shape:
   *            - {Array<String>} list: The list of autocomplete items
   *            - {Array<String>} postLabelValue: The list of autocomplete items
   *              post labels (e.g. for variable names, their values).
   */
  #getAutocompleteDataForFunction(functionStackEntry) {
    const functionName = functionStackEntry?.fnToken?.value;
    if (!functionName) {
      return null;
    }

    let list = [];
    let postLabelValues = [];

    if (functionName === "var") {
      // We only want to return variables for the first parameters of var(), not for its
      // fallback. If we get more than one tokens, and given we don't get comments or
      // whitespace, this means we're in the fallback value already.
      if (functionStackEntry.tokens.length > 1) {
        // In such case we'll use the default behavior
        return null;
      }
      list = this.#getCSSVariableNames();
      postLabelValues = list.map(varName => this.#getCSSVariableValue(varName));
    } else if (functionName.includes("gradient")) {
      // For gradient functions we want to display named colors and color functions,
      // but only if the user didn't already entered a color token after the last comma.
      list = this.#getCSSValuesForPropertyName("color");
    }

    // TODO: Handle other functions, e.g. color functions to autocomplete on relative
    // color format (Bug 1898273), `color()` to suggest color space (Bug 1898277),
    // `anchor()` to display existing anchor names (Bug 1903278)

    return { list, postLabelValues };
  }

  /**
   * Automatically add closing parenthesis and skip closing parenthesis when needed.
   */
  #autocloseParenthesis() {
    // Split the current value at the cursor index to rebuild the string.
    const { selectionStart, selectionEnd } = this.input;

    const parts = this.#splitStringAt(
      this.input.value,
      // Use selectionEnd, so when an autocomplete item was inserted, we put the closing
      // parenthesis after the suggestion
      selectionEnd
    );

    // Lookup the character following the caret to know if the string should be modified.
    const nextChar = parts[1][0];

    // Autocomplete closing parenthesis if the last key pressed was "(" and the next
    // character is not a "word" character.
    if (this.#pressedKey == "(" && !isWordChar(nextChar)) {
      this.#updateValue(parts[0] + ")" + parts[1]);
    }

    // Skip inserting ")" if the next character is already a ")" (note that we actually
    // insert and remove the extra ")" here, as the input has already been modified).
    if (this.#pressedKey == ")" && nextChar == ")") {
      this.#updateValue(parts[0] + parts[1].substring(1));
    }

    // set original selection range
    this.input.setSelectionRange(selectionStart, selectionEnd);

    this.#pressedKey = null;
  }

  /**
   * Update the current value of the input while preserving the caret position.
   */
  #updateValue(str) {
    const start = this.input.selectionStart;
    this.input.value = str;
    this.input.setSelectionRange(start, start);
    this.#updateSize();
  }

  /**
   * Split the provided string at the provided index. Returns an array of two strings.
   * _splitStringAt("1234567", 3) will return ["123", "4567"]
   */
  #splitStringAt(str, index) {
    return [str.substring(0, index), str.substring(index, str.length)];
  }

  /**
   * Check if the current input is displaying more than one line of text.
   *
   * @return {boolean} true if the input has a single line of text
   */
  #isSingleLine() {
    if (!this.multiline) {
      // Checking the inputCharDimensions.height only makes sense with multiline
      // editors, because the textarea is directly sized using
      // inputCharDimensions (see _updateSize()).
      // By definition if !this.multiline, then we are in single line mode.
      return true;
    }
    const inputRect = this.input.getBoundingClientRect();
    return inputRect.height < 2 * this.inputCharDimensions.height;
  }

  /**
   * Returns the list of CSS properties to use for the autocompletion. This
   * method is overridden by tests in order to use mocked suggestion lists.
   *
   * @return {Array} array of CSS property names (Strings)
   */
  #getCSSPropertyList() {
    return this.cssProperties.getNames().sort();
  }

  /**
   * Returns a list of CSS values valid for a provided property name to use for
   * the autocompletion. This method is overridden by tests in order to use
   * mocked suggestion lists.
   *
   * @param {string} propertyName
   * @return {Array} array of CSS property values (Strings)
   */
  #getCSSValuesForPropertyName(propertyName) {
    const gridLineList = [];
    if (this.gridLineNames) {
      if (GRID_ROW_PROPERTY_NAMES.includes(this.property.name)) {
        gridLineList.push(...this.gridLineNames.rows);
      }
      if (GRID_COL_PROPERTY_NAMES.includes(this.property.name)) {
        gridLineList.push(...this.gridLineNames.cols);
      }
    }
    // Must be alphabetically sorted before comparing the results with
    // the user input, otherwise we will lose some results.
    return gridLineList
      .concat(this.cssProperties.getValues(propertyName))
      .sort();
  }

  #getCSSVariablesMap() {
    if (!this.getCssVariables) {
      return null;
    }

    if (!this.#variables) {
      this.#variables = this.getCssVariables();
    }
    return this.#variables;
  }

  /**
   * Returns the list of all CSS variables to use for the autocompletion.
   *
   * @return {Array} array of CSS variable names (Strings)
   */
  #getCSSVariableNames() {
    if (!this.#variableNames) {
      const variables = this.#getCSSVariablesMap();
      if (!variables) {
        return [];
      }
      this.#variableNames = Array.from(variables.keys()).sort();
    }
    return this.#variableNames;
  }

  /**
   * Returns the variable's value for the given CSS variable name.
   *
   * @param {string} varName
   *        The variable name to retrieve the value of
   * @return {string} the variable value to the given CSS variable name
   */
  #getCSSVariableValue(varName) {
    return this.#getCSSVariablesMap()?.get(varName);
  }
}

exports.InplaceEditor = InplaceEditor;

/**
 * Copy text-related styles from one element to another.
 */
function copyTextStyles(from, to) {
  const win = from.ownerDocument.defaultView;
  const style = win.getComputedStyle(from);

  to.style.fontFamily = style.fontFamily;
  to.style.fontSize = style.fontSize;
  to.style.fontWeight = style.fontWeight;
  to.style.fontStyle = style.fontStyle;
}

/**
 * Copy all styles which could have an impact on the element size.
 */
function copyAllStyles(from, to) {
  const win = from.ownerDocument.defaultView;
  const style = win.getComputedStyle(from);

  copyTextStyles(from, to);
  to.style.lineHeight = style.lineHeight;

  // If box-sizing is set to border-box, box model styles also need to be
  // copied.
  const boxSizing = style.boxSizing;
  if (boxSizing === "border-box") {
    to.style.boxSizing = boxSizing;
    copyBoxModelStyles(from, to);
  }
}

/**
 * Copy box model styles that can impact width and height measurements when box-
 * sizing is set to "border-box" instead of "content-box".
 *
 * @param {DOMNode} from
 *        the element from which styles are copied
 * @param {DOMNode} to
 *        the element on which copied styles are applied
 */
function copyBoxModelStyles(from, to) {
  const properties = [
    // Copy all paddings.
    "paddingTop",
    "paddingRight",
    "paddingBottom",
    "paddingLeft",
    // Copy border styles.
    "borderTopStyle",
    "borderRightStyle",
    "borderBottomStyle",
    "borderLeftStyle",
    // Copy border widths.
    "borderTopWidth",
    "borderRightWidth",
    "borderBottomWidth",
    "borderLeftWidth",
  ];

  const win = from.ownerDocument.defaultView;
  const style = win.getComputedStyle(from);
  for (const property of properties) {
    to.style[property] = style[property];
  }
}

/**
 * Trigger a focus change similar to pressing tab/shift-tab.
 *
 * @param {Window} win: The window into which the focus should be moved
 * @param {number} direction: See Services.focus.MOVEFOCUS_*
 * @param {boolean} focusEditableField: Set to true to move the focus to the previous/next
 *        editable field. If not set, the focus will be set on the next focusable element.
 *        The function might still put the focus on a non-editable field, if none is found
 *        within the element matching focusEditableFieldContainerSelector
 * @param {string} focusEditableFieldContainerSelector: A CSS selector the editabled element
 *        we want to focus should be in. This is only used when focusEditableField is set
 *        to true.
 *        It's important to pass a boundary otherwise we might hit an infinite loop
 * @returns {Element} The element that received the focus
 */
function moveFocus(
  win,
  direction,
  focusEditableField,
  focusEditableFieldContainerSelector
) {
  if (!focusEditableField) {
    return focusManager.moveFocus(win, null, direction, 0);
  }

  if (!win.document.querySelector(focusEditableFieldContainerSelector)) {
    console.error(
      focusEditableFieldContainerSelector,
      "can't be found in document.",
      `focusEditableFieldContainerSelector should match an existing element`
    );
    return focusManager.moveFocus(win, null, direction, 0);
  }

  // Let's look for the next/previous editable element to focus
  while (true) {
    const focusedElement = focusManager.moveFocus(win, null, direction, 0);
    // The _editable property is set by the InplaceEditor on the target element
    if (focusedElement._editable) {
      return focusedElement;
    }

    // If the focus was moved outside of the container, simply return the focused element
    if (!focusedElement.closest(focusEditableFieldContainerSelector)) {
      return focusedElement;
    }
  }
}
