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

const allProperties = require("./generated/allProperties");
const implementedProperties = require("./generated/implementedProperties");
const generatedProperties = require("./generated/properties");
const {
  borderProperties,
  getPositionValue,
  normalizeProperties,
  prepareBorderProperties,
  prepareProperties,
  shorthandProperties
} = require("./normalize");
const {
  hasVarFunc,
  isGlobalKeyword,
  parseCSS,
  parsePropertyValue,
  prepareValue
} = require("./parsers");
const allExtraProperties = require("./utils/allExtraProperties");
const { dashedToCamelCase } = require("./utils/camelize");
const { getPropertyDescriptor } = require("./utils/propertyDescriptors");
const { asciiLowercase } = require("./utils/strings");

/**
 * @see https://drafts.csswg.org/cssom/#the-cssstyledeclaration-interface
 */
class CSSStyleDeclaration {
  /**
   * @param {Function} onChangeCallback
   * @param {object} [opt]
   * @param {object} [opt.context] - Window, Element or CSSRule.
   */
  constructor(onChangeCallback, opt = {}) {
    // Make constructor and internals non-enumerable.
    Object.defineProperties(this, {
      constructor: {
        enumerable: false,
        writable: true
      },

      // Window
      _global: {
        value: globalThis,
        enumerable: false,
        writable: true
      },

      // Element
      _ownerNode: {
        value: null,
        enumerable: false,
        writable: true
      },

      // CSSRule
      _parentNode: {
        value: null,
        enumerable: false,
        writable: true
      },

      _onChange: {
        value: null,
        enumerable: false,
        writable: true
      },

      _values: {
        value: new Map(),
        enumerable: false,
        writable: true
      },

      _priorities: {
        value: new Map(),
        enumerable: false,
        writable: true
      },

      _length: {
        value: 0,
        enumerable: false,
        writable: true
      },

      _computed: {
        value: false,
        enumerable: false,
        writable: true
      },

      _readonly: {
        value: false,
        enumerable: false,
        writable: true
      },

      _setInProgress: {
        value: false,
        enumerable: false,
        writable: true
      }
    });

    const { context } = opt;
    if (context) {
      if (typeof context.getComputedStyle === "function") {
        this._global = context;
        this._computed = 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;
        }
      }
    }
    if (typeof onChangeCallback === "function") {
      this._onChange = onChangeCallback;
    }
  }

  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(" ");
  }

  set cssText(val) {
    if (this._readonly) {
      const msg = "cssText can not be modified.";
      const name = "NoModificationAllowedError";
      throw new this._global.DOMException(msg, name);
    }
    Array.prototype.splice.call(this, 0, this._length);
    this._values.clear();
    this._priorities.clear();
    if (this._parentRule || (this._ownerNode && this._setInProgress)) {
      return;
    }
    try {
      this._setInProgress = true;
      const valueObj = parseCSS(
        val,
        {
          context: "declarationList",
          parseValue: false
        },
        true
      );
      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, {
                globalObject: this._global
              });
              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, {
          globalObject: this._global
        });
        for (const [property, item] of parsedProperties) {
          const { priority, value } = item;
          this._priorities.set(property, priority);
          this.setProperty(property, value, priority);
        }
      }
    } catch {
      return;
    } finally {
      this._setInProgress = false;
    }
    if (typeof this._onChange === "function") {
      this._onChange(this.cssText);
    }
  }

  get length() {
    return this._length;
  }

  // This deletes indices if the new length is less then the current length.
  // If the new length is more, it does nothing, the new indices will be
  // undefined until set.
  set length(len) {
    for (let i = len; i < this._length; i++) {
      delete this[i];
    }
    this._length = len;
  }

  // Readonly
  get parentRule() {
    return this._parentRule;
  }

  get cssFloat() {
    return this.getPropertyValue("float");
  }

  set cssFloat(value) {
    this._setProperty("float", value);
  }

  /**
   * @param {string} property
   */
  getPropertyPriority(property) {
    return this._priorities.get(property) || "";
  }

  /**
   * @param {string} property
   */
  getPropertyValue(property) {
    if (this._values.has(property)) {
      return this._values.get(property).toString();
    }
    return "";
  }

  /**
   * @param {...number} args
   */
  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];
  }

  /**
   * @param {string} 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 = Array.prototype.indexOf.call(this, property);
    if (index >= 0) {
      Array.prototype.splice.call(this, index, 1);
      if (typeof this._onChange === "function") {
        this._onChange(this.cssText);
      }
    }
    return prevValue;
  }

  /**
   * @param {string} prop
   * @param {string} val
   * @param {string} prior
   */
  setProperty(prop, val, prior) {
    if (this._readonly) {
      const msg = `Property ${prop} can not be modified.`;
      const name = "NoModificationAllowedError";
      throw new this._global.DOMException(msg, name);
    }
    const value = prepareValue(val);
    if (value === "") {
      this[prop] = "";
      this.removeProperty(prop);
      return;
    }
    const priority = prior === "important" ? "important" : "";
    const isCustomProperty = prop.startsWith("--");
    if (isCustomProperty) {
      this._setProperty(prop, value, priority);
      return;
    }
    const property = asciiLowercase(prop);
    if (!allProperties.has(property) && !allExtraProperties.has(property)) {
      return;
    }
    if (priority) {
      this._priorities.set(property, priority);
    } else {
      this._priorities.delete(property);
    }
    this[property] = value;
  }
}

// Internal methods
Object.defineProperties(CSSStyleDeclaration.prototype, {
  _setProperty: {
    /**
     * @param {string} property
     * @param {string} val
     * @param {string} priority
     */
    value(property, val, priority) {
      if (typeof val !== "string") {
        return;
      }
      if (val === "") {
        this.removeProperty(property);
        return;
      }
      let originalText = "";
      if (typeof this._onChange === "function" && !this._setInProgress) {
        originalText = this.cssText;
      }
      if (this._values.has(property)) {
        const index = Array.prototype.indexOf.call(this, property);
        // The property already exists but is not indexed into `this` so add it.
        if (index < 0) {
          this[this._length] = property;
          this._length++;
        }
      } else {
        // New property.
        this[this._length] = property;
        this._length++;
      }
      if (priority === "important") {
        this._priorities.set(property, priority);
      } else {
        this._priorities.delete(property);
      }
      this._values.set(property, val);
      if (
        typeof this._onChange === "function" &&
        !this._setInProgress &&
        this.cssText !== originalText
      ) {
        this._onChange(this.cssText);
      }
    },
    enumerable: false
  },

  _borderSetter: {
    /**
     * @param {string} prop
     * @param {object|Array|string} val
     * @param {string} prior
     */
    value(prop, val, prior) {
      const properties = new Map();
      if (prop === "border") {
        let priority = "";
        if (typeof prior === "string") {
          priority = prior;
        } else {
          priority = this._priorities.get(prop) ?? "";
        }
        properties.set(prop, { propery: prop, value: val, priority });
      } else {
        for (let i = 0; i < this._length; i++) {
          const property = this[i];
          if (borderProperties.has(property)) {
            const value = this.getPropertyValue(property);
            const longhandPriority = this._priorities.get(property) ?? "";
            let priority = longhandPriority;
            if (prop === property && typeof prior === "string") {
              priority = prior;
            }
            properties.set(property, { property, value, priority });
          }
        }
      }
      const parsedProperties = prepareBorderProperties(prop, val, prior, properties, {
        globalObject: this._global
      });
      for (const [property, item] of parsedProperties) {
        const { priority, value } = item;
        this._setProperty(property, value, priority);
      }
    },
    enumerable: false
  },

  _flexBoxSetter: {
    /**
     * @param {string} prop
     * @param {string} val
     * @param {string} prior
     * @param {string} shorthandProperty
     */
    value(prop, val, prior, shorthandProperty) {
      if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) {
        return;
      }
      const shorthandPriority = this._priorities.get(shorthandProperty);
      this.removeProperty(shorthandProperty);
      let priority = "";
      if (typeof prior === "string") {
        priority = prior;
      } else {
        priority = this._priorities.get(prop) ?? "";
      }
      this.removeProperty(prop);
      if (shorthandPriority && priority) {
        this._setProperty(prop, val);
      } else {
        this._setProperty(prop, val, priority);
      }
      if (val && !hasVarFunc(val)) {
        const longhandValues = [];
        const shorthandItem = shorthandProperties.get(shorthandProperty);
        let hasGlobalKeyword = false;
        for (const [longhandProperty] of shorthandItem.shorthandFor) {
          if (longhandProperty === prop) {
            if (isGlobalKeyword(val)) {
              hasGlobalKeyword = true;
            }
            longhandValues.push(val);
          } 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((value) => value === firstValue)) {
              this._setProperty(shorthandProperty, firstValue, priority);
            }
          } else {
            const parsedValue = shorthandItem.parse(longhandValues.join(" "));
            const shorthandValue = Object.values(parsedValue).join(" ");
            this._setProperty(shorthandProperty, shorthandValue, priority);
          }
        }
      }
    },
    enumerable: false
  },

  _positionShorthandSetter: {
    /**
     * @param {string} prop
     * @param {Array|string} val
     * @param {string} prior
     */
    value(prop, val, prior) {
      if (!shorthandProperties.has(prop)) {
        return;
      }
      const shorthandValues = [];
      if (Array.isArray(val)) {
        shorthandValues.push(...val);
      } else if (typeof val === "string") {
        shorthandValues.push(val);
      } else {
        return;
      }
      let priority = "";
      if (typeof prior === "string") {
        priority = prior;
      } else {
        priority = this._priorities.get(prop) ?? "";
      }
      const { position, shorthandFor } = shorthandProperties.get(prop);
      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(prop);
      } else {
        const shorthandValue = getPositionValue(shorthandValues, position);
        this._setProperty(prop, shorthandValue, priority);
      }
    },
    enumerable: false
  },

  _positionLonghandSetter: {
    /**
     * @param {string} prop
     * @param {string} val
     * @param {string} prior
     * @param {string} shorthandProperty
     */
    value(prop, val, prior, shorthandProperty) {
      if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) {
        return;
      }
      const shorthandPriority = this._priorities.get(shorthandProperty);
      this.removeProperty(shorthandProperty);
      let priority = "";
      if (typeof prior === "string") {
        priority = prior;
      } else {
        priority = this._priorities.get(prop) ?? "";
      }
      this.removeProperty(prop);
      if (shorthandPriority && priority) {
        this._setProperty(prop, val);
      } else {
        this._setProperty(prop, val, priority);
      }
      if (val && !hasVarFunc(val)) {
        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);
        }
      }
    },
    enumerable: false
  }
});

// Properties
Object.defineProperties(CSSStyleDeclaration.prototype, generatedProperties);

// Additional properties
[...allProperties, ...allExtraProperties].forEach((property) => {
  if (!implementedProperties.has(property)) {
    const declaration = getPropertyDescriptor(property);
    Object.defineProperty(CSSStyleDeclaration.prototype, property, declaration);
    const camel = dashedToCamelCase(property);
    Object.defineProperty(CSSStyleDeclaration.prototype, camel, declaration);
    if (/^webkit[A-Z]/.test(camel)) {
      const pascal = camel.replace(/^webkit/, "Webkit");
      Object.defineProperty(CSSStyleDeclaration.prototype, pascal, declaration);
    }
  }
});

module.exports = {
  CSSStyleDeclaration,
  propertyList: Object.fromEntries(implementedProperties)
};
