/**
 * This is a fork from the CSS Style Declaration part of
 * https://github.com/NV/CSSOM
 */
"use strict";

const propertyDescriptors = require("./generated/propertyDescriptors");
const {
  borderProperties,
  getPositionValue,
  normalizeProperties,
  prepareBorderProperties,
  prepareProperties,
  shorthandProperties
} = require("./normalize");
const { hasVarFunc, isGlobalKeyword, parseCSS, parsePropertyValue, prepareValue } = require("./parsers");
const { asciiLowercase } = require("./utils/strings");

/**
 * @see https://drafts.csswg.org/cssom/#the-cssstyledeclaration-interface
 */
class CSSStyleDeclaration {
  /**
   * Creates a new CSSStyleDeclaration instance.
   *
   * @param {Function} [onChangeCallback] - Callback triggered when style changes.
   * @param {object} [opt] - Options.
   * @param {object} [opt.context] - The context object (Window, Element, or CSSRule).
   */
  constructor(onChangeCallback, { context } = {}) {
    // Internals for jsdom
    this._global = globalThis;
    this._onChange = onChangeCallback;

    // Internals for CSS declaration block
    // @see https://drafts.csswg.org/cssom/#css-declaration-blocks
    this._computed = false;
    this._ownerNode = null;
    this._parentRule = null;
    this._readonly = false;
    this._updating = false;

    // Other internals
    this._length = 0;
    this._propertyIndices = new Map();
    this._priorities = new Map();
    this._values = new Map();

    if (context) {
      if (typeof context.getComputedStyle === "function") {
        this._global = context;
        this._computed = true;
        // FIXME: The `_readonly` flag should initially be `false` to be editable,
        // but should eventually be set to `true`.
        // this._readonly = true;
      } else if (context.nodeType === 1 && Object.hasOwn(context, "style")) {
        this._global = context.ownerDocument.defaultView;
        this._ownerNode = context;
      } else if (Object.hasOwn(context, "parentRule")) {
        this._parentRule = context;
        // Find Window from the owner node of the StyleSheet.
        const window = context?.parentStyleSheet?.ownerNode?.ownerDocument?.defaultView;
        if (window) {
          this._global = window;
        }
      }
    }
  }

  /**
   * Returns the textual representation of the declaration block.
   *
   * @returns {string} The serialized CSS text.
   */
  get cssText() {
    if (this._computed) {
      return "";
    }
    const properties = new Map();
    for (let i = 0; i < this._length; i++) {
      const property = this[i];
      const value = this.getPropertyValue(property);
      const priority = this._priorities.get(property) ?? "";
      if (shorthandProperties.has(property)) {
        const { shorthandFor } = shorthandProperties.get(property);
        for (const [longhand] of shorthandFor) {
          if (priority || !this._priorities.get(longhand)) {
            properties.delete(longhand);
          }
        }
      }
      properties.set(property, { property, value, priority });
    }
    const normalizedProperties = normalizeProperties(properties);
    const parts = [];
    for (const { property, value, priority } of normalizedProperties.values()) {
      if (priority) {
        parts.push(`${property}: ${value} !${priority};`);
      } else {
        parts.push(`${property}: ${value};`);
      }
    }
    return parts.join(" ");
  }

  /**
   * Sets the textual representation of the declaration block.
   * This clears all existing properties and parses the new CSS text.
   *
   * @param {string} text - The new CSS text.
   */
  set cssText(text) {
    if (this._readonly) {
      const msg = "cssText can not be modified.";
      const name = "NoModificationAllowedError";
      throw new this._global.DOMException(msg, name);
    }
    this._clearIndexedProperties();
    this._values.clear();
    this._priorities.clear();
    if (this._parentRule || (this._ownerNode && this._updating)) {
      return;
    }
    try {
      this._updating = true;
      const valueObj = parseCSS(text, { context: "declarationList", parseValue: false });
      if (valueObj?.children) {
        const properties = new Map();
        let shouldSkipNext = false;
        for (const item of valueObj.children) {
          if (item.type === "Atrule") {
            continue;
          }
          if (item.type === "Rule") {
            shouldSkipNext = true;
            continue;
          }
          if (shouldSkipNext === true) {
            shouldSkipNext = false;
            continue;
          }
          const {
            important,
            property,
            value: { value }
          } = item;
          if (typeof property === "string" && typeof value === "string") {
            const priority = important ? "important" : "";
            const isCustomProperty = property.startsWith("--");
            if (isCustomProperty || hasVarFunc(value)) {
              if (properties.has(property)) {
                const { priority: itemPriority } = properties.get(property);
                if (!itemPriority) {
                  properties.set(property, { property, value, priority });
                }
              } else {
                properties.set(property, { property, value, priority });
              }
            } else {
              const parsedValue = parsePropertyValue(property, value);
              if (parsedValue) {
                if (properties.has(property)) {
                  const { priority: itemPriority } = properties.get(property);
                  if (!itemPriority) {
                    properties.set(property, { property, value, priority });
                  }
                } else {
                  properties.set(property, { property, value, priority });
                }
              } else {
                this.removeProperty(property);
              }
            }
          }
        }
        const parsedProperties = prepareProperties(properties);
        for (const [property, item] of parsedProperties) {
          const { priority, value } = item;
          this._priorities.set(property, priority);
          this.setProperty(property, value, priority);
        }
      }
    } catch {
      return;
    } finally {
      this._updating = false;
    }
    if (this._onChange) {
      this._onChange(this.cssText);
    }
  }

  /**
   * Returns the number of properties in the declaration block.
   *
   * @returns {number} The property count.
   */
  get length() {
    return this._length;
  }

  /**
   * Returns the CSSRule that is the parent of this declaration block.
   *
   * @returns {object|null} The parent CSSRule or null.
   */
  get parentRule() {
    return this._parentRule;
  }

  /**
   * Alias for the "float" property.
   *
   * @returns {string} The value of the "float" property.
   */
  get cssFloat() {
    return this.getPropertyValue("float");
  }

  /**
   * Sets the "float" property.
   *
   * @param {string} value - The new value for "float".
   */
  set cssFloat(value) {
    this._setProperty("float", value);
  }

  /**
   * Returns the priority of the specified property (e.g. "important").
   *
   * @param {string} property - The property name.
   * @returns {string} The priority string, or empty string if not set.
   */
  getPropertyPriority(property) {
    return this._priorities.get(property) || "";
  }

  /**
   * Returns the value of the specified property.
   *
   * @param {string} property - The property name.
   * @returns {string} The property value, or empty string if not set.
   */
  getPropertyValue(property) {
    if (this._values.has(property)) {
      return this._values.get(property).toString();
    }
    return "";
  }

  /**
   * Returns the property name at the specified index.
   *
   * @param {...number} args - The index (only the first argument is used).
   * @returns {string} The property name, or empty string if index is invalid.
   */
  item(...args) {
    if (!args.length) {
      const msg = "1 argument required, but only 0 present.";
      throw new this._global.TypeError(msg);
    }
    const [value] = args;
    const index = parseInt(value);
    if (Number.isNaN(index) || index < 0 || index >= this._length) {
      return "";
    }
    return this[index];
  }

  /**
   * Removes the specified property from the declaration block.
   *
   * @param {string} property - The property name to remove.
   * @returns {string} The value of the removed property.
   */
  removeProperty(property) {
    if (this._readonly) {
      const msg = `Property ${property} can not be modified.`;
      const name = "NoModificationAllowedError";
      throw new this._global.DOMException(msg, name);
    }
    if (!this._values.has(property)) {
      return "";
    }
    const prevValue = this._values.get(property);
    this._values.delete(property);
    this._priorities.delete(property);
    const index = this._getIndexOf(property);
    if (index >= 0) {
      this._removeIndexedProperty(index);
      if (this._onChange) {
        this._onChange(this.cssText);
      }
    }
    return prevValue;
  }

  /**
   * Sets a property value with an optional priority.
   *
   * @param {string} property - The property name.
   * @param {string} value - The property value.
   * @param {string} [priority=""] - The priority (e.g. "important").
   */
  setProperty(property, value, priority = "") {
    if (this._readonly) {
      const msg = `Property ${property} can not be modified.`;
      const name = "NoModificationAllowedError";
      throw new this._global.DOMException(msg, name);
    }
    value = prepareValue(value);
    if (value === "") {
      if (Object.hasOwn(propertyDescriptors, property)) {
        // TODO: Refactor handlers to not require `.call()`.
        propertyDescriptors[property].set.call(this, value);
      }
      this.removeProperty(property);
      return;
    }
    // Custom property
    if (property.startsWith("--")) {
      this._setProperty(property, value, priority);
      return;
    }
    property = asciiLowercase(property);
    if (!Object.hasOwn(propertyDescriptors, property)) {
      return;
    }
    if (priority) {
      this._priorities.set(property, priority);
    } else {
      this._priorities.delete(property);
    }
    propertyDescriptors[property].set.call(this, value);
  }

  /**
   * Clears all indexed properties, properties indices and resets length to 0.
   *
   * @private
   */
  _clearIndexedProperties() {
    this._propertyIndices.clear();
    for (let i = 0; i < this._length; i++) {
      delete this[i];
    }
    this._length = 0;
  }

  /**
   * Removes an indexed property at the specified index, shifts others, and updates indices.
   *
   * @private
   * @param {number} index - The index of the property to remove.
   */
  _removeIndexedProperty(index) {
    this._propertyIndices.delete(this[index]);
    for (let i = index; i < this._length - 1; i++) {
      const property = this[i + 1];
      this[i] = property;
      this._propertyIndices.set(property, i);
    }
    delete this[this._length - 1];
    this._length--;
  }

  /**
   * Returns the index of the specified property.
   *
   * @private
   * @param {string} property - The property name to search for.
   * @returns {number} The index of the property, or -1 if not found.
   */
  _getIndexOf(property) {
    return this._propertyIndices.get(property) ?? -1;
  }

  /**
   * Sets a property and update indices.
   *
   * @private
   * @param {string} property - The property name.
   * @param {string} value - The property value.
   * @param {string} priority - The priority.
   */
  _setProperty(property, value, priority) {
    if (typeof value !== "string") {
      return;
    }
    if (value === "") {
      this.removeProperty(property);
      return;
    }
    let originalText = "";
    if (this._onChange && !this._updating) {
      originalText = this.cssText;
    }
    if (!this._values.has(property)) {
      // New property.
      this[this._length] = property;
      this._propertyIndices.set(property, this._length);
      this._length++;
    }
    if (priority === "important") {
      this._priorities.set(property, priority);
    } else {
      this._priorities.delete(property);
    }
    this._values.set(property, value);
    if (this._onChange && !this._updating && this.cssText !== originalText) {
      this._onChange(this.cssText);
    }
  }

  /**
   * Helper to handle border property expansion.
   *
   * @private
   * @param {string} property - The property name (e.g. "border").
   * @param {object|Array|string} value - The value to set.
   * @param {string} priority - The priority.
   */
  _borderSetter(property, value, priority) {
    const properties = new Map();
    if (typeof priority !== "string") {
      priority = this._priorities.get(property) ?? "";
    }
    if (property === "border") {
      properties.set(property, { propery: property, value, priority });
    } else {
      for (let i = 0; i < this._length; i++) {
        const itemProperty = this[i];
        if (borderProperties.has(itemProperty)) {
          const itemValue = this.getPropertyValue(itemProperty);
          const longhandPriority = this._priorities.get(itemProperty) ?? "";
          let itemPriority = longhandPriority;
          if (itemProperty === property) {
            itemPriority = priority;
          }
          properties.set(itemProperty, {
            property: itemProperty,
            value: itemValue,
            priority: itemPriority
          });
        }
      }
    }
    const parsedProperties = prepareBorderProperties(property, value, priority, properties);
    for (const [itemProperty, item] of parsedProperties) {
      const { priority: itemPriority, value: itemValue } = item;
      this._setProperty(itemProperty, itemValue, itemPriority);
    }
  }

  /**
   * Helper to handle flexbox shorthand expansion.
   *
   * @private
   * @param {string} property - The property name.
   * @param {string} value - The property value.
   * @param {string} priority - The priority.
   * @param {string} shorthandProperty - The shorthand property name.
   */
  _flexBoxSetter(property, value, priority, shorthandProperty) {
    if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) {
      return;
    }
    const shorthandPriority = this._priorities.get(shorthandProperty);
    this.removeProperty(shorthandProperty);
    if (typeof priority !== "string") {
      priority = this._priorities.get(property) ?? "";
    }
    this.removeProperty(property);
    if (shorthandPriority && priority) {
      this._setProperty(property, value);
    } else {
      this._setProperty(property, value, priority);
    }
    if (value && !hasVarFunc(value)) {
      const longhandValues = [];
      const shorthandItem = shorthandProperties.get(shorthandProperty);
      let hasGlobalKeyword = false;
      for (const [longhandProperty] of shorthandItem.shorthandFor) {
        if (longhandProperty === property) {
          if (isGlobalKeyword(value)) {
            hasGlobalKeyword = true;
          }
          longhandValues.push(value);
        } else {
          const longhandValue = this.getPropertyValue(longhandProperty);
          const longhandPriority = this._priorities.get(longhandProperty) ?? "";
          if (!longhandValue || longhandPriority !== priority) {
            break;
          }
          if (isGlobalKeyword(longhandValue)) {
            hasGlobalKeyword = true;
          }
          longhandValues.push(longhandValue);
        }
      }
      if (longhandValues.length === shorthandItem.shorthandFor.size) {
        if (hasGlobalKeyword) {
          const [firstValue, ...restValues] = longhandValues;
          if (restValues.every((val) => val === firstValue)) {
            this._setProperty(shorthandProperty, firstValue, priority);
          }
        } else {
          const parsedValue = shorthandItem.parse(longhandValues.join(" "));
          const shorthandValue = Object.values(parsedValue).join(" ");
          this._setProperty(shorthandProperty, shorthandValue, priority);
        }
      }
    }
  }

  /**
   * Helper to handle position shorthand expansion.
   *
   * @private
   * @param {string} property - The property name.
   * @param {Array|string} value - The property value.
   * @param {string} priority - The priority.
   */
  _positionShorthandSetter(property, value, priority) {
    if (!shorthandProperties.has(property)) {
      return;
    }
    const shorthandValues = [];
    if (Array.isArray(value)) {
      shorthandValues.push(...value);
    } else if (typeof value === "string") {
      shorthandValues.push(value);
    } else {
      return;
    }
    if (typeof priority !== "string") {
      priority = this._priorities.get(property) ?? "";
    }
    const { position, shorthandFor } = shorthandProperties.get(property);
    let hasPriority = false;
    for (const [longhandProperty, longhandItem] of shorthandFor) {
      const { position: longhandPosition } = longhandItem;
      const longhandValue = getPositionValue(shorthandValues, longhandPosition);
      if (priority) {
        this._setProperty(longhandProperty, longhandValue, priority);
      } else {
        const longhandPriority = this._priorities.get(longhandProperty) ?? "";
        if (longhandPriority) {
          hasPriority = true;
        } else {
          this._setProperty(longhandProperty, longhandValue, priority);
        }
      }
    }
    if (hasPriority) {
      this.removeProperty(property);
    } else {
      const shorthandValue = getPositionValue(shorthandValues, position);
      this._setProperty(property, shorthandValue, priority);
    }
  }

  /**
   * Helper to handle position longhand updates affecting shorthands.
   *
   * @private
   * @param {string} property - The property name.
   * @param {string} value - The property value.
   * @param {string} priority - The priority.
   * @param {string} shorthandProperty - The shorthand property name.
   */
  _positionLonghandSetter(property, value, priority, shorthandProperty) {
    if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) {
      return;
    }
    const shorthandPriority = this._priorities.get(shorthandProperty);
    this.removeProperty(shorthandProperty);
    if (typeof priority !== "string") {
      priority = this._priorities.get(property) ?? "";
    }
    this.removeProperty(property);
    if (shorthandPriority && priority) {
      this._setProperty(property, value);
    } else {
      this._setProperty(property, value, priority);
    }
    if (value && !hasVarFunc(value)) {
      const longhandValues = [];
      const { shorthandFor, position: shorthandPosition } = shorthandProperties.get(shorthandProperty);
      for (const [longhandProperty] of shorthandFor) {
        const longhandValue = this.getPropertyValue(longhandProperty);
        const longhandPriority = this._priorities.get(longhandProperty) ?? "";
        if (!longhandValue || longhandPriority !== priority) {
          return;
        }
        longhandValues.push(longhandValue);
      }
      if (longhandValues.length === shorthandFor.size) {
        const replacedValue = getPositionValue(longhandValues, shorthandPosition);
        this._setProperty(shorthandProperty, replacedValue);
      }
    }
  }
}

// TODO: Remove once the CSSStyleDeclaration is fully spec-compliant.
// @see https://github.com/jsdom/cssstyle/issues/255#issuecomment-3630183207
Object.defineProperties(CSSStyleDeclaration.prototype, propertyDescriptors);

module.exports = {
  CSSStyleDeclaration
};
