/* 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 {
  gDevTools,
} = require("resource://devtools/client/framework/devtools.js");
const {
  getCssVariableColor,
} = require("resource://devtools/client/shared/theme.js");
const {
  createFactory,
  createElement,
} = require("resource://devtools/client/shared/vendor/react.mjs");
const {
  Provider,
} = require("resource://devtools/client/shared/vendor/react-redux.js");
const { debounce } = require("resource://devtools/shared/debounce.js");
const {
  style: { ELEMENT_STYLE },
} = require("resource://devtools/shared/constants.js");

const FontsApp = createFactory(
  require("resource://devtools/client/inspector/fonts/components/FontsApp.js")
);

const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const INSPECTOR_L10N = new LocalizationHelper(
  "devtools/client/locales/inspector.properties"
);

const {
  parseFontVariationAxes,
} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");

const fontDataReducer = require("resource://devtools/client/inspector/fonts/reducers/fonts.js");
const fontEditorReducer = require("resource://devtools/client/inspector/fonts/reducers/font-editor.js");
const fontOptionsReducer = require("resource://devtools/client/inspector/fonts/reducers/font-options.js");
const {
  updateFonts,
} = require("resource://devtools/client/inspector/fonts/actions/fonts.js");
const {
  applyInstance,
  resetFontEditor,
  setEditorDisabled,
  updateAxis,
  updateFontEditor,
  updateFontProperty,
} = require("resource://devtools/client/inspector/fonts/actions/font-editor.js");
const {
  updatePreviewText,
} = require("resource://devtools/client/inspector/fonts/actions/font-options.js");
const { TYPES: HIGHLIGHTER_TYPES } = ChromeUtils.importESModule(
  "resource://devtools/shared/highlighters.mjs"
);

const FONT_PROPERTIES = [
  "font-family",
  "font-optical-sizing",
  "font-size",
  "font-stretch",
  "font-style",
  "font-variation-settings",
  "font-weight",
  "letter-spacing",
  "line-height",
];
const REGISTERED_AXES_TO_FONT_PROPERTIES = {
  ital: "font-style",
  opsz: "font-optical-sizing",
  slnt: "font-style",
  wdth: "font-stretch",
  wght: "font-weight",
};
const REGISTERED_AXES = Object.keys(REGISTERED_AXES_TO_FONT_PROPERTIES);

class FontInspector {
  constructor(inspector, window) {
    this.cssProperties = inspector.cssProperties;
    this.document = window.document;
    this.inspector = inspector;
    // Selected node in the markup view. For text nodes, this points to their parent node
    // element. Font faces and font properties for this node will be shown in the editor.
    this.node = null;
    this.nodeComputedStyle = {};
    // The page style actor that will be providing the style information.
    this.pageStyle = null;
    this.ruleViewTool = this.inspector.getPanel("ruleview");
    this.ruleView = this.ruleViewTool.view;
    this.selectedRule = null;
    this.store = this.inspector.store;
    // Map CSS property names and variable font axis names to methods that write their
    // corresponding values to the appropriate TextProperty from the Rule view.
    // Values of variable font registered axes may be written to CSS font properties under
    // certain cascade circumstances and platform support. @see `getWriterForAxis(axis)`
    this.writers = new Map();

    this.store.injectReducer("fontOptions", fontOptionsReducer);
    this.store.injectReducer("fontData", fontDataReducer);
    this.store.injectReducer("fontEditor", fontEditorReducer);

    this.syncChanges = debounce(this.syncChanges, 100, this);
    this.onInstanceChange = this.onInstanceChange.bind(this);
    this.onNewNode = this.onNewNode.bind(this);
    this.onPreviewTextChange = debounce(this.onPreviewTextChange, 100, this);
    this.onPropertyChange = this.onPropertyChange.bind(this);
    this.onRulePropertyUpdated = debounce(
      this.onRulePropertyUpdated,
      300,
      this
    );
    this.onToggleFontHighlight = this.onToggleFontHighlight.bind(this);
    this.onThemeChanged = this.onThemeChanged.bind(this);
    this.update = this.update.bind(this);
    this.updateFontVariationSettings =
      this.updateFontVariationSettings.bind(this);

    this.init();
  }

  /**
   * Map CSS font property names to a list of values that should be skipped when consuming
   * font properties from CSS rules. The skipped values are mostly keyword values like
   * `bold`, `initial`, `unset`. Computed values will be used instead of such keywords.
   *
   * @return {Map}
   */
  get skipValuesMap() {
    if (!this._skipValuesMap) {
      this._skipValuesMap = new Map();

      for (const property of FONT_PROPERTIES) {
        const values = this.cssProperties.getValues(property);

        switch (property) {
          case "line-height":
          case "letter-spacing":
            // There's special handling for "normal" so remove it from the skip list.
            this.skipValuesMap.set(
              property,
              values.filter(value => value !== "normal")
            );
            break;
          default:
            this.skipValuesMap.set(property, values);
        }
      }
    }

    return this._skipValuesMap;
  }

  init() {
    if (!this.inspector) {
      return;
    }

    const fontsApp = FontsApp({
      onInstanceChange: this.onInstanceChange,
      onToggleFontHighlight: this.onToggleFontHighlight,
      onPreviewTextChange: this.onPreviewTextChange,
      onPropertyChange: this.onPropertyChange,
    });

    const provider = createElement(
      Provider,
      {
        id: "fontinspector",
        key: "fontinspector",
        store: this.store,
        title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"),
      },
      fontsApp
    );

    // Expose the provider to let inspector.js use it in setupSidebar.
    this.provider = provider;

    this.inspector.selection.on("new-node-front", this.onNewNode);
    // @see ToolSidebar.onSidebarTabSelected()
    this.inspector.sidebar.on("fontinspector-selected", this.onNewNode);

    // Listen for theme changes as the color of the previews depend on the theme
    gDevTools.on("theme-switched", this.onThemeChanged);
  }

  /**
   * Convert a value for font-size between two CSS unit types.
   * Conversion is done via pixels. If neither of the two given unit types is "px",
   * recursively get the value in pixels, then convert that result to the desired unit.
   *
   * @param  {string} property
   *         Property name for the converted value.
   *         Assumed to be "font-size", but special case for "line-height".
   * @param  {number} value
   *         Numeric value to convert.
   * @param  {string} fromUnit
   *         CSS unit to convert from.
   * @param  {string} toUnit
   *         CSS unit to convert to.
   * @return {number}
   *         Converted numeric value.
   */
  async convertUnits(property, value, fromUnit, toUnit) {
    if (value !== parseFloat(value)) {
      throw TypeError(
        `Invalid value for conversion. Expected Number, got ${value}`
      );
    }

    const shouldReturn = () => {
      // Early return if:
      // - conversion is not required
      // - property is `line-height`
      // - `fromUnit` is `em` and `toUnit` is unitless
      const conversionNotRequired = fromUnit === toUnit || value === 0;
      const forLineHeight =
        property === "line-height" && fromUnit === "" && toUnit === "em";
      const isEmToUnitlessConversion = fromUnit === "em" && toUnit === "";
      return conversionNotRequired || forLineHeight || isEmToUnitlessConversion;
    };

    if (shouldReturn()) {
      return value;
    }

    // If neither unit is in pixels, first convert the value to pixels.
    // Reassign input value and source CSS unit.
    if (toUnit !== "px" && fromUnit !== "px") {
      value = await this.convertUnits(property, value, fromUnit, "px");
      fromUnit = "px";
    }

    // Whether the conversion is done from pixels.
    const fromPx = fromUnit === "px";
    // Determine the target CSS unit for conversion.
    const unit = toUnit === "px" ? fromUnit : toUnit;
    // Default output value to input value for a 1-to-1 conversion as a guard against
    // unrecognized CSS units. It will not be correct, but it will also not break.
    let out = value;

    const converters = {
      in: () => (fromPx ? value / 96 : value * 96),
      cm: () => (fromPx ? value * 0.02645833333 : value / 0.02645833333),
      mm: () => (fromPx ? value * 0.26458333333 : value / 0.26458333333),
      pt: () => (fromPx ? value * 0.75 : value / 0.75),
      pc: () => (fromPx ? value * 0.0625 : value / 0.0625),
      "%": async () => {
        const fontSize = await this.getReferenceFontSize(property, unit);
        return fromPx
          ? (value * 100) / parseFloat(fontSize)
          : (value / 100) * parseFloat(fontSize);
      },
      rem: async () => {
        const fontSize = await this.getReferenceFontSize(property, unit);
        return fromPx
          ? value / parseFloat(fontSize)
          : value * parseFloat(fontSize);
      },
      vh: async () => {
        const { height } = await this.getReferenceBox(property, unit);
        return fromPx ? (value * 100) / height : (value / 100) * height;
      },
      vw: async () => {
        const { width } = await this.getReferenceBox(property, unit);
        return fromPx ? (value * 100) / width : (value / 100) * width;
      },
      vmin: async () => {
        const { width, height } = await this.getReferenceBox(property, unit);
        return fromPx
          ? (value * 100) / Math.min(width, height)
          : (value / 100) * Math.min(width, height);
      },
      vmax: async () => {
        const { width, height } = await this.getReferenceBox(property, unit);
        return fromPx
          ? (value * 100) / Math.max(width, height)
          : (value / 100) * Math.max(width, height);
      },
    };

    if (converters.hasOwnProperty(unit)) {
      const converter = converters[unit];
      out = await converter();
    }

    // Special handling for unitless line-height.
    if (unit === "em" || (unit === "" && property === "line-height")) {
      const fontSize = await this.getReferenceFontSize(property, unit);
      out = fromPx
        ? value / parseFloat(fontSize)
        : value * parseFloat(fontSize);
    }

    // Catch any NaN or Infinity as result of dividing by zero in any
    // of the relative unit conversions which rely on external values.
    if (isNaN(out) || Math.abs(out) === Infinity) {
      out = 0;
    }

    // Return values limited to 3 decimals when:
    // - the unit is converted from pixels to something else
    // - the value is for letter spacing, regardless of unit (allow sub-pixel precision)
    if (fromPx || property === "letter-spacing") {
      // Round values like 1.000 to 1
      return out === Math.round(out) ? Math.round(out) : out.toFixed(3);
    }

    // Round pixel values.
    return Math.round(out);
  }

  /**
   * Destruction function called when the inspector is destroyed. Removes event listeners
   * and cleans up references.
   */
  destroy() {
    this.inspector.selection.off("new-node-front", this.onNewNode);
    this.inspector.sidebar.off("fontinspector-selected", this.onNewNode);
    this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
    gDevTools.off("theme-switched", this.onThemeChanged);

    this.document = null;
    this.inspector = null;
    this.node = null;
    this.nodeComputedStyle = {};
    this.pageStyle = null;
    this.ruleView = null;
    this.selectedRule = null;
    this.store = null;
    this.writers.clear();
    this.writers = null;
  }

  /**
   * Get all expected CSS font properties and values from the node's matching rules and
   * fallback to computed style. Skip CSS Custom Properties, `calc()` and keyword values.
   *
   * @return {object}
   */
  async getFontProperties() {
    const properties = {};

    // First, get all expected font properties from computed styles, if available.
    for (const prop of FONT_PROPERTIES) {
      properties[prop] =
        this.nodeComputedStyle[prop] && this.nodeComputedStyle[prop].value
          ? this.nodeComputedStyle[prop].value
          : "";
    }

    // Then, replace with enabled font properties found on any of the rules that apply.
    for (const rule of this.ruleView.rules) {
      if (rule.inherited) {
        continue;
      }

      for (const textProp of rule.textProps) {
        if (
          FONT_PROPERTIES.includes(textProp.name) &&
          !this.skipValuesMap.get(textProp.name).includes(textProp.value) &&
          !textProp.value.includes("calc(") &&
          !textProp.value.includes("var(") &&
          !textProp.overridden &&
          textProp.enabled
        ) {
          properties[textProp.name] = textProp.value;
        }
      }
    }

    return properties;
  }

  async getFontsForNode(node, options) {
    // In case we've been destroyed in the meantime
    if (!this.document) {
      return [];
    }

    const fonts = await this.pageStyle
      .getUsedFontFaces(node, options)
      .catch(console.error);
    if (!fonts) {
      return [];
    }

    return fonts;
  }

  async getAllFonts(options) {
    // In case we've been destroyed in the meantime
    if (!this.document) {
      return [];
    }

    const inspectorFronts = await this.inspector.getAllInspectorFronts();

    let allFonts = [];
    for (const { pageStyle } of inspectorFronts) {
      allFonts = allFonts.concat(await pageStyle.getAllUsedFontFaces(options));
    }

    return allFonts;
  }

  /**
   * Get the box dimensions used for unit conversion according to the CSS property and
   * target CSS unit.
   *
   * @param  {string} property
   *         CSS property
   * @param  {string} unit
   *         Target CSS unit
   * @return {Promise}
   *         Promise that resolves with an object with box dimensions in pixels.
   */
  async getReferenceBox(property, unit) {
    const box = { width: 0, height: 0 };
    const node = await this.getReferenceNode(property, unit).catch(
      console.error
    );

    if (!node) {
      return box;
    }

    switch (unit) {
      case "vh":
      case "vw":
      case "vmin":
      case "vmax": {
        const dim = await node.getOwnerGlobalDimensions().catch(console.error);
        if (dim) {
          box.width = dim.innerWidth;
          box.height = dim.innerHeight;
        }
        break;
      }
      case "%": {
        const style = await this.pageStyle
          .getComputed(node)
          .catch(console.error);
        if (style) {
          box.width = style.width.value;
          box.height = style.height.value;
        }
        break;
      }
    }

    return box;
  }

  /**
   * Get the refernece font size value used for unit conversion according to the
   * CSS property and target CSS unit.
   *
   * @param {string} property
   *        CSS property
   * @param {string} unit
   *        Target CSS unit
   * @return {Promise}
   *         Promise that resolves with the reference font size value or null if there
   *         was an error getting that value.
   */
  async getReferenceFontSize(property, unit) {
    const node = await this.getReferenceNode(property, unit).catch(
      console.error
    );
    if (!node) {
      return null;
    }

    const style = await this.pageStyle.getComputed(node).catch(console.error);
    if (!style) {
      return null;
    }

    return style["font-size"].value;
  }

  /**
   * Get the reference node used in measurements for unit conversion according to the
   * the CSS property and target CSS unit type.
   *
   * @param  {string} property
   *         CSS property
   * @param  {string} unit
   *         Target CSS unit
   * @return {Promise}
   *          Promise that resolves with the reference node used in measurements for unit
   *          conversion.
   */
  async getReferenceNode(property, unit) {
    let node;

    switch (property) {
      case "line-height":
      case "letter-spacing":
        node = this.node;
        break;
      default:
        node = this.node.parentNode();
    }

    switch (unit) {
      case "rem":
        // Regardless of CSS property, always use the root document element for "rem".
        node = await this.node.walkerFront.documentElement();
        break;
    }

    return node;
  }

  /**
   * Get a reference to a TextProperty instance from the current selected rule for a
   * given property name.
   *
   * @param {string} name
   *        CSS property name
   * @return {TextProperty|null}
   */
  getTextProperty(name) {
    if (!this.selectedRule) {
      return null;
    }

    return this.selectedRule.textProps.find(
      prop => prop.name === name && prop.enabled && !prop.overridden
    );
  }

  /**
   * Given the axis name of a registered axis, return a method which updates the
   * corresponding CSS font property when called with a value.
   *
   * All variable font axes can be written in the value of the "font-variation-settings"
   * CSS font property. In CSS Fonts Level 4, registered axes values can be used as
   * values of font properties, like "font-weight", "font-stretch" and "font-style".
   *
   * Axes declared in "font-variation-settings", either on the rule or inherited,
   * overwrite any corresponding font properties. Updates to these axes must be written
   * to "font-variation-settings" to preserve the cascade. Authors are discouraged from
   * using this practice. Whenever possible, registered axes values should be written to
   * their corresponding font properties.
   *
   * Registered axis name to font property mapping:
   *  - wdth -> font-stretch
   *  - wght -> font-weight
   *  - opsz -> font-optical-sizing
   *  - slnt -> font-style
   *  - ital -> font-style
   *
   * @param {string} axis
   *        Name of registered axis.
   * @return {Function}
   *         Method to call which updates the corresponding CSS font property.
   */
  getWriterForAxis(axis) {
    // Find any declaration of "font-variation-setttings".
    const FVSComputedStyle = this.nodeComputedStyle["font-variation-settings"];

    // If "font-variation-settings" CSS property is defined (on the rule or inherited)
    // and contains a declaration for the given registered axis, write to it.
    if (FVSComputedStyle && FVSComputedStyle.value.includes(axis)) {
      return this.updateFontVariationSettings;
    }

    // Get corresponding CSS font property value for registered axis.
    const property = REGISTERED_AXES_TO_FONT_PROPERTIES[axis];

    return value => {
      let condition = false;

      switch (axis) {
        case "wght":
          // Whether the page supports values of font-weight from CSS Fonts Level 4.
          condition = this.pageStyle.supportsFontWeightLevel4;
          break;

        case "wdth":
          // font-stretch in CSS Fonts Level 4 accepts percentage units.
          value = `${value}%`;
          // Whether the page supports values of font-stretch from CSS Fonts Level 4.
          condition = this.pageStyle.supportsFontStretchLevel4;
          break;

        case "slnt":
          // font-style in CSS Fonts Level 4 accepts an angle value.
          // We have to invert the sign of the angle because CSS and OpenType measure
          // in opposite directions.
          value = -value;
          value = `oblique ${value}deg`;
          // Whether the page supports values of font-style from CSS Fonts Level 4.
          condition = this.pageStyle.supportsFontStyleLevel4;
          break;
      }

      if (condition) {
        this.updatePropertyValue(property, value);
      } else {
        // Replace the writer method for this axis so it won't get called next time.
        this.writers.set(axis, this.updateFontVariationSettings);
        // Fall back to writing to font-variation-settings together with all other axes.
        this.updateFontVariationSettings();
      }
    };
  }

  /**
   * Given a CSS property name or axis name of a variable font, return a method which
   * updates the corresponding CSS font property when called with a value.
   *
   * This is used to distinguish between CSS font properties, registered axes and
   * custom axes. Registered axes, like "wght" and "wdth", should be written to
   * corresponding CSS properties, like "font-weight" and "font-stretch".
   *
   * Unrecognized names (which aren't font property names or registered axes names) are
   * considered to be custom axes names and will be written to the
   * "font-variation-settings" CSS property.
   *
   * @param {string} name
   *        CSS property name or axis name.
   * @return {Function}
   *         Method which updates the rule view and page style.
   */
  getWriterForProperty(name) {
    if (this.writers.has(name)) {
      return this.writers.get(name);
    }

    if (REGISTERED_AXES.includes(name)) {
      this.writers.set(name, this.getWriterForAxis(name));
    } else if (FONT_PROPERTIES.includes(name)) {
      this.writers.set(name, value => {
        this.updatePropertyValue(name, value);
      });
    } else {
      this.writers.set(name, this.updateFontVariationSettings);
    }

    return this.writers.get(name);
  }

  /**
   * Check if the font inspector panel is visible.
   *
   * @return {boolean}
   */
  isPanelVisible() {
    return (
      this.inspector &&
      this.inspector.sidebar &&
      this.inspector.sidebar.getCurrentTabID() === "fontinspector"
    );
  }

  /**
   * Upon a new node selection, log some interesting telemetry probes.
   */
  logTelemetryProbesOnNewNode() {
    const { fontEditor } = this.store.getState();

    // Log data about the currently edited font (if any).
    // Note that the edited font is always the first one from the fontEditor.fonts array.
    const editedFont = fontEditor.fonts[0];
    if (!editedFont) {
      return;
    }

    const nbOfAxes = editedFont.variationAxes
      ? editedFont.variationAxes.length
      : 0;
    Glean.devtoolsInspector.fonteditorFontTypeDisplayed[
      !nbOfAxes ? "nonvariable" : "variable"
    ].add(1);
  }

  /**
   * Sync the Rule view with the latest styles from the page. Called in a debounced way
   * (see constructor) after property changes are applied directly to the CSS style rule
   * on the page circumventing direct TextProperty.setValue() which triggers expensive DOM
   * operations in TextPropertyEditor.update().
   *
   * @param  {string} name
   *         CSS property name
   * @param  {string} value
   *         CSS property value
   */
  async syncChanges(name, value) {
    const textProperty = this.getTextProperty(name, value);
    if (textProperty) {
      try {
        await textProperty.setValue(value, "");
        this.ruleView.on("property-value-updated", this.onRulePropertyUpdated);
      } catch (error) {
        // Because setValue() does an asynchronous call to the server, there is a chance
        // the font editor was destroyed while we were waiting. If that happened, just
        // bail out silently.
        if (!this.document) {
          return;
        }

        throw error;
      }
    }
  }

  /**
   * Handler for changes of a font axis value coming from the FontEditor.
   *
   * @param  {string} tag
   *         Tag name of the font axis.
   * @param  {number} value
   *         Value of the font axis.
   */
  onAxisUpdate(tag, value) {
    this.store.dispatch(updateAxis(tag, value));
    const writer = this.getWriterForProperty(tag);
    writer(value.toString());
  }

  /**
   * Handler for changes of a CSS font property value coming from the FontEditor.
   *
   * @param  {string} property
   *         CSS font property name.
   * @param  {number} value
   *         CSS font property numeric value.
   * @param  {string | null} unit
   *         CSS unit or null
   */
  onFontPropertyUpdate(property, value, unit) {
    value = unit !== null ? value + unit : value;
    this.store.dispatch(updateFontProperty(property, value));
    const writer = this.getWriterForProperty(property);
    writer(value.toString());
  }

  /**
   * Handler for selecting a font variation instance. Dispatches an action which updates
   * the axes and their values as defined by that variation instance.
   *
   * @param {string} name
   *        Name of variation instance. (ex: Light, Regular, Ultrabold, etc.)
   * @param {Array} values
   *        Array of objects with axes and values defined by the variation instance.
   */
  onInstanceChange(name, values) {
    this.store.dispatch(applyInstance(name, values));
    let writer;
    values.map(obj => {
      writer = this.getWriterForProperty(obj.axis);
      writer(obj.value.toString());
    });
  }

  /**
   * Event handler for "new-node-front" event fired when a new node is selected in the
   * markup view.
   *
   * Sets the selected node for which font faces and font properties will be
   * shown in the font editor. If the selection is a text node, use its parent element.
   *
   * Triggers a refresh of the font editor and font overview if the panel is visible.
   */
  onNewNode() {
    this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);

    // First, reset the selected node and page style front.
    this.node = null;
    this.pageStyle = null;

    // Then attempt to assign a selected node according to its type.
    const selection = this.inspector && this.inspector.selection;
    if (selection && selection.isConnected()) {
      if (selection.isElementNode()) {
        this.node = selection.nodeFront;
      } else if (selection.isTextNode()) {
        this.node = selection.nodeFront.parentNode();
      }

      this.pageStyle = this.node.inspectorFront.pageStyle;
    }

    if (this.isPanelVisible()) {
      Promise.all([this.update(), this.refreshFontEditor()])
        .then(() => {
          this.logTelemetryProbesOnNewNode();
        })
        .catch(e => console.error(e));
    }
  }

  /**
   * Handler for change in preview input.
   */
  onPreviewTextChange(value) {
    this.store.dispatch(updatePreviewText(value));
    this.update();
  }

  /**
   * Handler for changes to any CSS font property value or variable font axis value coming
   * from the Font Editor. This handler calls the appropriate method to preview the
   * changes on the page and update the store.
   *
   * If the property parameter is not a recognized CSS font property name, assume it's a
   * variable font axis name.
   *
   * @param  {string} property
   *         CSS font property name or axis name
   * @param  {number} value
   *         CSS font property value or axis value
   * @param  {string | undefined} fromUnit
   *         Optional CSS unit to convert from
   * @param  {string | undefined} toUnit
   *         Optional CSS unit to convert to
   */
  async onPropertyChange(property, value, fromUnit, toUnit) {
    if (FONT_PROPERTIES.includes(property)) {
      let unit = fromUnit;

      // Strict checks because "line-height" value may be unitless (empty string).
      if (toUnit !== undefined && fromUnit !== undefined) {
        value = await this.convertUnits(property, value, fromUnit, toUnit);
        unit = toUnit;
      }

      this.onFontPropertyUpdate(property, value, unit);
    } else {
      this.onAxisUpdate(property, value);
    }
  }

  /**
   * Handler for "property-value-updated" event emitted from the rule view whenever a
   * property value changes. Ignore changes to properties unrelated to the font editor.
   *
   * @param {object} eventData
   *        Object with the property name and value and origin rule.
   *        Example: { name: "font-size", value: "1em", rule: Object }
   */
  async onRulePropertyUpdated(eventData) {
    if (!this.selectedRule || !FONT_PROPERTIES.includes(eventData.property)) {
      return;
    }

    if (this.isPanelVisible()) {
      await this.refreshFontEditor();
    }
  }

  /**
   * Reveal a font's usage in the page.
   *
   * @param  {string} font
   *         The name of the font to be revealed in the page.
   * @param  {boolean} show
   *         Whether or not to reveal the font.
   * @param  {boolean} isForCurrentElement
   *         Optional. Default `true`. Whether or not to restrict revealing the font
   *         just to the current element selection.
   */
  async onToggleFontHighlight(font, show, isForCurrentElement = true) {
    try {
      if (show) {
        const node = isForCurrentElement
          ? this.inspector.selection.nodeFront
          : this.node.walkerFront.rootNode;

        await this.inspector.highlighters.showHighlighterTypeForNode(
          HIGHLIGHTER_TYPES.FONTS,
          node,
          {
            CSSFamilyName: font.CSSFamilyName,
            name: font.name,
          }
        );
      } else {
        await this.inspector.highlighters.hideHighlighterType(
          HIGHLIGHTER_TYPES.FONTS
        );
      }
    } catch (e) {
      // Silently handle protocol errors here, because these might be called during
      // shutdown of the browser or devtools, and we don't care if they fail.
    }
  }

  /**
   * Handler for the "theme-switched" event.
   */
  onThemeChanged(frame) {
    if (frame === this.document.defaultView) {
      this.update();
    }
  }

  /**
   * Update the state of the font editor with:
   * - the fonts which apply to the current node;
   * - the computed style CSS font properties of the current node.
   *
   * This method is called:
   * - when a new node is selected;
   * - when any property is changed in the Rule view.
   * For the latter case, we compare between the latest computed style font properties
   * and the ones already in the store to decide if to update the font editor state.
   */
  async refreshFontEditor() {
    if (!this.node) {
      this.store.dispatch(resetFontEditor());
      return;
    }

    const options = {};
    if (this.pageStyle.supportsFontVariations) {
      options.includeVariations = true;
    }

    const fonts = await this.getFontsForNode(this.node, options);

    try {
      // Get computed styles for the selected node, but filter by CSS font properties.
      this.nodeComputedStyle = await this.pageStyle.getComputed(this.node, {
        filterProperties: FONT_PROPERTIES,
      });
    } catch (e) {
      // Because getComputed is async, there is a chance the font editor was
      // destroyed while we were waiting. If that happened, just bail out
      // silently.
      if (!this.document) {
        return;
      }

      throw e;
    }

    if (!this.nodeComputedStyle || !fonts.length) {
      this.store.dispatch(resetFontEditor());
      this.inspector.emit("fonteditor-updated");
      return;
    }

    // Clear any references to writer methods and CSS declarations because the node's
    // styles may have changed since the last font editor refresh.
    this.writers.clear();

    // If the Rule panel is not visible, the selected element's rule models may not have
    // been created yet. For example, in 2-pane mode when Fonts is opened as the default
    // panel. Select the current node to force the Rule view to create the rule models.
    if (!this.ruleViewTool.isPanelVisible()) {
      await this.ruleView.selectElement(this.node, false);
    }

    // Select the node's inline style as the rule where to write property value changes.
    this.selectedRule = this.ruleView.rules.find(
      rule => rule.domRule.type === ELEMENT_STYLE
    );

    const properties = await this.getFontProperties();
    // Assign writer methods to each axis defined in font-variation-settings.
    const axes = parseFontVariationAxes(properties["font-variation-settings"]);
    Object.keys(axes).map(axis => {
      this.writers.set(axis, this.getWriterForAxis(axis));
    });

    this.store.dispatch(updateFontEditor(fonts, properties, this.node.actorID));
    this.store.dispatch(setEditorDisabled(this.node.isPseudoElement));

    this.inspector.emit("fonteditor-updated");
    // Listen to manual changes in the Rule view that could update the Font Editor state
    this.ruleView.on("property-value-updated", this.onRulePropertyUpdated);
  }

  async update() {
    // Stop refreshing if the inspector or store is already destroyed.
    if (!this.inspector || !this.store) {
      return;
    }

    let allFonts = [];

    if (!this.node) {
      this.store.dispatch(updateFonts(allFonts));
      return;
    }

    const { fontOptions } = this.store.getState();
    const { previewText } = fontOptions;

    const options = {
      includePreviews: true,
      // Coerce the type of `supportsFontVariations` to a boolean.
      includeVariations: !!this.pageStyle.supportsFontVariations,
      previewText,
      previewFillStyle: getCssVariableColor(
        "--theme-body-color",
        this.document.documentGlobal
      ),
    };

    // If there are no fonts used on the page, the result is an empty array.
    allFonts = await this.getAllFonts(options);

    // Augment each font object with a dataURI for an image with a sample of the font.
    for (const font of [...allFonts]) {
      font.previewUrl = await font.preview.data.string();
    }

    // Dispatch to the store if it hasn't been destroyed in the meantime.
    this.store && this.store.dispatch(updateFonts(allFonts));
    // Emit on the inspector if it hasn't been destroyed in the meantime.
    // Pass the current node in the payload so that tests can check the update
    // corresponds to the expected node.
    this.inspector &&
      this.inspector.emitForTests("fontinspector-updated", this.node);
  }

  /**
   * Update the "font-variation-settings" CSS property with the state of all touched
   * font variation axes which shouldn't be written to other CSS font properties.
   */
  updateFontVariationSettings() {
    const fontEditor = this.store.getState().fontEditor;
    const name = "font-variation-settings";
    const value = Object.keys(fontEditor.axes)
      // Pick only axes which are supposed to be written to font-variation-settings.
      // Skip registered axes which should be written to a different CSS property.
      .filter(tag => this.writers.get(tag) === this.updateFontVariationSettings)
      // Build a string value for the "font-variation-settings" CSS property
      .map(tag => `"${tag}" ${fontEditor.axes[tag]}`)
      .join(", ");

    this.updatePropertyValue(name, value);
  }

  /**
   * Preview a property value (live) then sync the changes (debounced) to the Rule view.
   *
   * NOTE: Until Bug 1462591 is addressed, all changes are written to the element's inline
   * style attribute. In this current scenario, Rule.setPropertyValue()
   * causes the whole inline style representation in the Rule view to update instead of
   * just previewing the change on the element.
   * We keep the debounced call to syncChanges() because it explicitly calls
   * TextProperty.setValue() which performs other actions, including marking the property
   * as "changed" in the Rule view with a green indicator.
   *
   * @param {string} name
   *        CSS property name
   * @param {string}value
   *        CSS property value
   */
  updatePropertyValue(name, value) {
    const textProperty = this.getTextProperty(name);

    if (!textProperty) {
      this.selectedRule.createProperty(name, value, "", true);
      return;
    }

    if (textProperty.value === value) {
      return;
    }

    // Prevent reacting to changes we caused.
    this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
    // Live preview font property changes on the page.
    textProperty.rule
      .setPropertyValue(textProperty, value, "")
      .catch(console.error);

    // Sync Rule view with changes reflected on the page (debounced).
    this.syncChanges(name, value);
  }
}

module.exports = FontInspector;
