/* 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 {
  VIEW_NODE_CSS_QUERY_CONTAINER,
  VIEW_NODE_CSS_SELECTOR_WARNINGS,
  VIEW_NODE_FONT_TYPE,
  VIEW_NODE_IMAGE_URL_TYPE,
  VIEW_NODE_INACTIVE_CSS,
  VIEW_NODE_LOCATION_TYPE,
  VIEW_NODE_PROPERTY_TYPE,
  VIEW_NODE_SELECTOR_TYPE,
  VIEW_NODE_SHAPE_POINT_TYPE,
  VIEW_NODE_SHAPE_SWATCH,
  VIEW_NODE_VALUE_TYPE,
  VIEW_NODE_VARIABLE_TYPE,
} = require("resource://devtools/client/inspector/shared/node-types.js");
const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];

/**
 * Returns the [Rule] object associated with the given node.
 *
 * @param {DOMNode} node
 *        The node which we want to find the [Rule] object for
 * @param {ElementStyle} elementStyle
 *        The [ElementStyle] associated with the selected element
 * @return {Rule|null} associated with the given node
 */
function getRuleFromNode(node, elementStyle) {
  const ruleEl = node.closest(".ruleview-rule[data-rule-id]");
  const ruleId = ruleEl ? ruleEl.dataset.ruleId : null;
  return ruleId ? elementStyle.getRule(ruleId) : null;
}

/**
 * Returns the [TextProperty] object associated with the given node.
 *
 * @param {DOMNode} node
 *        The node which we want to find the [TextProperty] object for
 * @param {Rule|null} rule
 *        The [Rule] associated with the given node
 * @return {TextProperty|null} associated with the given node
 */
function getDeclarationFromNode(node, rule) {
  if (!rule) {
    return null;
  }

  const declarationEl = node.closest(".ruleview-property[data-declaration-id]");
  const declarationId = declarationEl
    ? declarationEl.dataset.declarationId
    : null;
  return rule ? rule.getDeclaration(declarationId) : null;
}

/**
 * Get the type of a given node in the Rules view.
 *
 * @param {DOMNode} node
 *        The node which we want information about
 * @param {ElementStyle} elementStyle
 *        The ElementStyle to which this rule belongs
 * @return {Object|null} containing the following props:
 * - rule {Rule} The Rule object.
 * - type {String} One of the VIEW_NODE_XXX_TYPE const in
 *   client/inspector/shared/node-types.
 * - value {Object} Depends on the type of the node.
 * - view {String} Always "rule" to indicate the rule view.
 * Otherwise, returns null if the node isn't anything we care about.
 */
// eslint-disable-next-line complexity
function getNodeInfo(node, elementStyle) {
  if (!node) {
    return null;
  }

  const rule = getRuleFromNode(node, elementStyle);
  const declaration = getDeclarationFromNode(node, rule);
  const classList = node.classList;

  let type, value;

  if (declaration && classList.contains("ruleview-propertyname")) {
    type = VIEW_NODE_PROPERTY_TYPE;
    value = {
      property: node.textContent,
      value: getPropertyNameAndValue(node).value,
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      pseudoElement: rule.pseudoElement,
      sheetHref: rule.domRule.href,
      textProperty: declaration,
    };
  } else if (declaration && classList.contains("ruleview-propertyvalue")) {
    type = VIEW_NODE_VALUE_TYPE;
    value = {
      property: getPropertyNameAndValue(node).name,
      value: node.textContent,
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      pseudoElement: rule.pseudoElement,
      sheetHref: rule.domRule.href,
      textProperty: declaration,
    };
  } else if (declaration && classList.contains("ruleview-font-family")) {
    const { name: propertyName, value: propertyValue } =
      getPropertyNameAndValue(node);
    type = VIEW_NODE_FONT_TYPE;
    value = {
      property: propertyName,
      value: propertyValue,
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      pseudoElement: rule.pseudoElement,
      sheetHref: rule.domRule.href,
      textProperty: declaration,
    };
  } else if (declaration && classList.contains("inspector-shape-point")) {
    type = VIEW_NODE_SHAPE_POINT_TYPE;
    value = {
      property: getPropertyNameAndValue(node).name,
      value: node.textContent,
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      pseudoElement: rule.pseudoElement,
      sheetHref: rule.domRule.href,
      textProperty: declaration,
      toggleActive: getShapeToggleActive(node),
      point: getShapePoint(node),
    };
  } else if (declaration && classList.contains("ruleview-unused-warning")) {
    type = VIEW_NODE_INACTIVE_CSS;
    value = declaration.isUsed();
  } else if (node.closest(".container-query-declaration")) {
    type = VIEW_NODE_CSS_QUERY_CONTAINER;
    const containerQueryEl = node.closest(".container-query");
    value = {
      ancestorIndex: containerQueryEl.getAttribute("data-ancestor-index"),
      rule,
    };
  } else if (node.classList.contains("ruleview-selector-warnings")) {
    type = VIEW_NODE_CSS_SELECTOR_WARNINGS;
    value = node.getAttribute("data-selector-warning-kind").split(",");
  } else if (declaration && classList.contains("inspector-shapeswatch")) {
    type = VIEW_NODE_SHAPE_SWATCH;
    value = {
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      textProperty: declaration,
    };
  } else if (
    declaration &&
    (classList.contains("inspector-variable") ||
      classList.contains("inspector-unmatched"))
  ) {
    type = VIEW_NODE_VARIABLE_TYPE;
    value = {
      property: getPropertyNameAndValue(node).name,
      value: node.textContent.trim(),
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      pseudoElement: rule.pseudoElement,
      sheetHref: rule.domRule.href,
      textProperty: declaration,
      variable: node.dataset.variable,
      variableComputed: node.dataset.variableComputed,
      startingStyleVariable: node.dataset.startingStyleVariable,
      registeredProperty: {
        initialValue: node.dataset.registeredPropertyInitialValue,
        syntax: node.dataset.registeredPropertySyntax,
        inherits: node.dataset.registeredPropertyInherits,
      },
      outputParserOptions: declaration.editor.outputParserOptions,
      cssProperties: declaration.editor.ruleView.cssProperties,
    };
  } else if (
    declaration &&
    classList.contains("theme-link") &&
    !classList.contains("ruleview-rule-source")
  ) {
    type = VIEW_NODE_IMAGE_URL_TYPE;
    value = {
      property: getPropertyNameAndValue(node).name,
      value: node.parentNode.textContent,
      url: node.href,
      enabled: declaration.enabled,
      overridden: declaration.overridden,
      pseudoElement: rule.pseudoElement,
      sheetHref: rule.domRule.href,
      textProperty: declaration,
    };
  } else if (
    classList.contains("ruleview-selectors-container") ||
    classList.contains("ruleview-selector") ||
    classList.contains("ruleview-selector-element") ||
    classList.contains("ruleview-selector-attribute") ||
    classList.contains("ruleview-selector-pseudo-class") ||
    classList.contains("ruleview-selector-pseudo-class-lock")
  ) {
    type = VIEW_NODE_SELECTOR_TYPE;
    value = rule.selectorText;
  } else if (
    classList.contains("ruleview-rule-source") ||
    classList.contains("ruleview-rule-source-label")
  ) {
    type = VIEW_NODE_LOCATION_TYPE;
    const sourceLabelEl = classList.contains("ruleview-rule-source-label")
      ? node
      : node.querySelector(".ruleview-rule-source-label");
    value =
      sourceLabelEl.getAttribute("href") || rule.sheet?.href || rule.title;
  } else {
    return null;
  }

  return {
    rule,
    type,
    value,
    view: "rule",
  };
}

/**
 * Walk up the DOM from a given node until a parent property holder is found,
 * and return the textContent for the name and value nodes.
 * Stops at the first property found, so if node is inside the computed property
 * list, the computed property will be returned
 *
 * @param {DOMNode} node
 *        The node to start from
 * @return {Object} {name, value}
 */
function getPropertyNameAndValue(node) {
  while (node?.classList) {
    // Check first for ruleview-computed since it's the deepest
    if (
      node.classList.contains("ruleview-computed") ||
      node.classList.contains("ruleview-property")
    ) {
      return {
        name: node.querySelector(".ruleview-propertyname").textContent,
        value: node.querySelector(".ruleview-propertyvalue").textContent,
      };
    }

    node = node.parentNode;
  }

  return null;
}

/**
 * Walk up the DOM from a given node until a parent property holder is found,
 * and return an active shape toggle if one exists.
 *
 * @param {DOMNode} node
 *        The node to start from
 * @returns {DOMNode} The active shape toggle node, if one exists.
 */
function getShapeToggleActive(node) {
  while (node?.classList) {
    // Check first for ruleview-computed since it's the deepest
    if (
      node.classList.contains("ruleview-computed") ||
      node.classList.contains("ruleview-property")
    ) {
      return node.querySelector(`.inspector-shapeswatch[aria-pressed="true"]`);
    }

    node = node.parentNode;
  }

  return null;
}

/**
 * Get the point associated with a shape point node.
 *
 * @param {DOMNode} node
 *        A shape point node
 * @returns {String} The point associated with the given node.
 */
function getShapePoint(node) {
  const classList = node.classList;
  let point = node.dataset.point;
  // Inset points use classes instead of data because a single span can represent
  // multiple points.
  const insetClasses = [];
  classList.forEach(className => {
    if (INSET_POINT_TYPES.includes(className)) {
      insetClasses.push(className);
    }
  });
  if (insetClasses.length) {
    point = insetClasses.join(",");
  }
  return point;
}

/**
 * Returns an array of CSS variables used in a CSS property value.
 * If no CSS variables are used, returns an empty array.
 *
 * @param {String} propertyValue
 *        CSS property value (e.g. "1px solid var(--color, blue)")
 * @return {Array}
 *         List of variable names (e.g. ["--color"])
 *
 */
function getCSSVariables(propertyValue = "") {
  const variables = [];
  const parts = propertyValue.split(/var\(\s*--/);

  if (parts.length) {
    // Skip first part. It is the substring before the first occurence of "var(--"
    for (let i = 1; i < parts.length; i++) {
      // Split the part by any of the following characters expected after a variable name:
      // comma, closing parenthesis or whitespace.
      // Take just the first match. Anything else is either:
      // - the fallback value, ex: ", blue" from "var(--color, blue)"
      // - the closing parenthesis, ex: ")" from "var(--color)"
      const variable = parts[i].split(/[,)\s+]/).shift();

      if (variable) {
        // Add back the double-dash. The initial string was split by "var(--"
        variables.push(`--${variable}`);
      }
    }
  }

  return variables;
}

/**
 * Get the CSS compatibility issue information for a given node.
 *
 * @param {DOMNode} node
 *        The node which we want compatibility information about
 * @param {ElementStyle} elementStyle
 *        The ElementStyle to which this rule belongs
 */
async function getNodeCompatibilityInfo(node, elementStyle) {
  const rule = getRuleFromNode(node, elementStyle);
  const declaration = getDeclarationFromNode(node, rule);
  const issue = await declaration.isCompatible();

  return issue;
}

/**
 * Returns true if the given CSS property value contains the given variable name.
 *
 * @param {String} propertyValue
 *        CSS property value (e.g. "var(--color)")
 * @param {String} variableName
 *        CSS variable name (e.g. "--color")
 * @return {Boolean}
 */
function hasCSSVariable(propertyValue, variableName) {
  return getCSSVariables(propertyValue).includes(variableName);
}

module.exports = {
  getCSSVariables,
  getNodeInfo,
  getRuleFromNode,
  hasCSSVariable,
  getNodeCompatibilityInfo,
};
