/*
 * Copyright (c) 2010, 2025 BSI Business Systems Integration AG
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */
import {arrays, numbers, scout, strings} from '../index';
import $ from 'jquery';

export interface Rgba {
  red: number;
  green: number;
  blue: number;
  alpha?: number;
}

export interface FontSpec {
  name?: string;
  size?: number;
  bold?: boolean;
  italic?: boolean;
}

export const styles = {
  styleMap: {},
  element: null as HTMLDivElement,

  /**
   * Generates an invisible div and appends it to the body, only once. The same div will be reused on subsequent calls.
   * Adds the given css class to that element and returns a style object containing the values for every given property.
   * The style is cached. Subsequent calls with the same css class will return the same style object.
   *
   * @param styleProperties in the form {backgroundColor: 'black'}
   */
  get(cssClass: string | string[], properties: string | string[], styleProperties?: Record<string, string>): Record<string, string> {
    // create invisible div
    let elem: HTMLDivElement = styles.element;
    if (!elem) {
      elem = window.document.createElement('div');
      elem.style.display = 'none';
      window.document.body.appendChild(elem);
      styles.element = elem;
    }

    let displayNoneStyleCssText = elem.style.cssText;
    styleProperties = $.extend(true, {}, styleProperties, {
      display: ''
    });
    Object.keys(styleProperties).sort().forEach(key => {
      elem.style[key] = styleProperties[key];
    });
    // get cssText as additional key component, display is not part of the key component
    let keyCssText = elem.style.cssText;
    // always add display: 'none'
    elem.style.display = 'none';
    let styleCssText = elem.style.cssText;

    // reset style
    elem.style.cssText = displayNoneStyleCssText;

    let cssClassArray = arrays.ensure(cssClass),
      mapKey = keyCssText ? [...cssClassArray, keyCssText] : cssClassArray;

    let style = styles.styleMap[mapKey.toString()];
    // ensure array
    properties = arrays.ensure(properties);
    let propertyNames = properties.map(prop => {
      return {
        name: prop,
        // replace property names like 'max-width' in 'maxWidth'
        nameCamelCase: prop.replace(/-(.)/g, (match, p1) => p1.toUpperCase())
      };
    });

    // ensure style
    if (!style) {
      style = {};
      styles.put(mapKey.toString(), style);
    }

    let notResolvedProperties = propertyNames.filter(prop => !(prop.nameCamelCase in style));
    if (notResolvedProperties.length === 0) {
      return style;
    }

    // resolve missing properties
    elem.className = cssClassArray[0];
    for (let i = 1; i < cssClassArray.length; i++) {
      let childElem: HTMLDivElement = elem.children[0] as HTMLDivElement;
      if (!childElem) {
        childElem = window.document.createElement('div');
        childElem.style.display = 'none';
        elem.appendChild(childElem);
      }
      elem = childElem;
      elem.className = cssClassArray[i];
    }

    // set style properties
    elem.style.cssText = styleCssText;

    let computedStyle = window.getComputedStyle(elem);
    notResolvedProperties.forEach(property => {
      style[property.nameCamelCase] = computedStyle[property.name];
    });

    elem.style.cssText = displayNoneStyleCssText;
    elem = styles.element;

    do {
      elem.className = '';
      elem = elem.children[0] as HTMLDivElement;
    }
    while (elem);

    return style;
  },

  /**
   * Traverses the parents of the given $elem and returns the first opaque background color.
   */
  getFirstOpaqueBackgroundColor($elem: JQuery<Element>): string {
    if (!$elem) {
      return;
    }

    let document = $elem.document(true);
    // @ts-expect-error
    while ($elem && $elem.length && document !== $elem[0]) {
      let rgbString = $elem.css('background-color'),
        rgba = styles.rgb(rgbString);
      if (rgba && rgba.alpha === 1) {
        return rgbString;
      }
      $elem = $elem.parent();
    }
  },

  getSize(cssClass: string | string[], cssProperty: string | string[], property: string, defaultSize?: number): number {
    let size = styles.get(cssClass, cssProperty)[property];
    if ('auto' === size) {
      return defaultSize;
    }
    return $.pxToNumber(size);
  },

  put(cssClass: string, style: Record<string, string>) {
    styles.styleMap[cssClass] = style;
  },

  clearCache() {
    styles.styleMap = {};
  },

  /**
   * Extracts the given attribute from the given CSS class and returns its value.
   *
   * If the provided class does not exist, the computed element style of the provided
   * attribute is returned. If the extracted value is unset, `null` is returned.
   *
   * @param cssClass any CSS class
   * @param attribute optional CSS attribute to extract the color from, default is _backgroundColor_
   */
  getCssColor(cssClass: string, attribute = 'backgroundColor'): string {
    let color = styles.get(cssClass, attribute)[attribute];
    return color === 'unset' ? null : color;
  },

  RGB_BLACK: {
    red: 0,
    green: 0,
    blue: 0
  } as Rgba,

  RGB_WHITE: {
    red: 255,
    green: 255,
    blue: 255
  } as Rgba,

  /**
   * Creates a rgb object based on the given rgb string with the format rgb(0, 0, 0).
   * If the input string cannot be parsed, undefined is returned.
   */
  rgb(rgbString: string): Rgba {
    if (!rgbString) {
      return undefined;
    }
    let rgbVal = rgbString.replace(/\s/g, '').match(/^rgba?\((\d+),(\d+),(\d+),?(\d+(\.\d+)?)?/i);
    if (rgbVal === null) {
      return undefined;
    }
    return {
      red: parseInt(rgbVal[1], 10),
      green: parseInt(rgbVal[2], 10),
      blue: parseInt(rgbVal[3], 10),
      alpha: parseFloat(scout.nvl(rgbVal[4], 1))
    };
  },

  /**
   * Converts the given hex string to a rgb string.
   */
  hexToRgb(hexString: string): string {
    if (!hexString) {
      return;
    }

    let r = 0,
      g = 0,
      b = 0,
      a = 255;

    if (hexString.length === 4 || hexString.length === 5) {
      r = parseInt('0x' + hexString[1] + hexString[1]);
      g = parseInt('0x' + hexString[2] + hexString[2]);
      b = parseInt('0x' + hexString[3] + hexString[3]);
      if (hexString.length === 5) {
        a = parseInt('0x' + hexString[4] + hexString[4]);
      }
    }

    if (hexString.length === 7 || hexString.length === 9) {
      r = parseInt('0x' + hexString[1] + hexString[2]);
      g = parseInt('0x' + hexString[3] + hexString[4]);
      b = parseInt('0x' + hexString[5] + hexString[6]);
      if (hexString.length === 9) {
        a = parseInt('0x' + hexString[7] + hexString[8]);
      }
    }

    a = +(a / 255).toFixed(3);

    return 'rgba(' + +r + ',' + +g + ',' + +b + ',' + a + ')';
  },

  /**
   * Returns the given rgb color in hex format.
   *
   * @param rgba a color in rgb or rgba format
   * @param forceRemoveAlpha true, if the alpha value should be removed, otherwise false.
   * @returns the color in hex format
   */
  rgbToHex(rgba: string, forceRemoveAlpha = false): string {
    if (!rgba) {
      return null;
    }

    const rgbaValues = rgba.replace(/^rgba?\(|\s+|\)$/g, '').split(','); // gets rgba/rgb string values
    const hexValues = [];

    for (let i = 0; i < rgbaValues.length; i++) {
      if (forceRemoveAlpha && i === 3) {
        continue;
      }
      let rgbaValue = parseFloat(rgbaValues[i]); // convert to numbers
      if (i === 3) {
        rgbaValue = Math.round(rgbaValue * 255); // convert alpha to 255 number
      }
      let hexValue = rgbaValue.toString(16); // convert number to hex
      if (hexValue.length === 1) {
        hexValue = '0' + hexValue; // add 0 when length of number is 1
      }
      hexValues[i] = hexValue;
    }

    return '#' + hexValues.join('');
  },

  /**
   * Make a given color darker by mixing it with a certain amount of black.
   * If no color is specified or the color cannot be parsed, undefined is returned.
   *
   * @param color
   *          a CSS color in 'rgb()' or 'rgba()' format.
   * @param ratio
   *          a number between 0 and 1 specifying how much black should be added
   *          to the given color (0.0 = only 'color', 1.0 = only black).
   *          Default is 0.2.
   */
  darkerColor(color: string, ratio?: number): string {
    let rgbVal = styles.rgb(color);
    if (!rgbVal) {
      return undefined;
    }
    ratio = scout.nvl(ratio, 0.2);
    return styles.mergeRgbColors(styles.RGB_BLACK, ratio, rgbVal, 1 - ratio);
  },

  /**
   * Make a given color lighter by mixing it with a certain amount of white.
   * If no color is specified or the color cannot be parsed, undefined is returned.
   *
   * @param color
   *          a CSS color in 'rgb()' or 'rgba()' format.
   * @param ratio
   *          a number between 0 and 1 specifying how much white should be added
   *          to the given color (0.0 = only 'color', 1.0 = only white).
   *          Default is 0.2.
   */
  lighterColor(color: string, ratio?: number): string {
    let rgbVal = styles.rgb(color);
    if (!rgbVal) {
      return undefined;
    }
    ratio = scout.nvl(ratio, 0.2);
    return styles.mergeRgbColors(styles.RGB_WHITE, ratio, rgbVal, 1 - ratio);
  },

  /**
   * Merges two RGB colors as defined by rgb().
   *
   * The two 'ratio' arguments specify "how much" of the corresponding color is added to the
   * resulting color. Both arguments should (but don't have to) add to 1.0.
   *
   * All arguments are mandatory.
   */
  mergeRgbColors(colorA: string | Rgba, ratio1?: number, colorB?: string | Rgba, ratio2?: number): string {
    let color1: Rgba, color2: Rgba;
    if (typeof colorA === 'string') {
      color1 = styles.rgb(colorA);
    } else {
      color1 = colorA;
    }
    if (typeof colorB === 'string') {
      color2 = styles.rgb(colorB);
    } else {
      color2 = colorB;
    }
    if (!color1 && !color2) {
      return undefined;
    }
    ratio1 = scout.nvl(ratio1, 0);
    ratio2 = scout.nvl(ratio2, 0);
    if (!color1) {
      color1 = styles.RGB_BLACK;
      ratio1 = 0;
    }
    if (!color2) {
      color2 = styles.RGB_BLACK;
      ratio2 = 0;
    }
    if (ratio1 === 0 && ratio2 === 0) {
      return 'rgb(0,0,0)';
    }
    return 'rgb(' +
      numbers.round((ratio1 * color1.red + ratio2 * color2.red) / (ratio1 + ratio2)) + ',' +
      numbers.round((ratio1 * color1.green + ratio2 * color2.green) / (ratio1 + ratio2)) + ',' +
      numbers.round((ratio1 * color1.blue + ratio2 * color2.blue) / (ratio1 + ratio2)) +
      ')';
  },

  /**
   * Example: Dialog-PLAIN-12
   */
  parseFontSpec(pattern: string): FontSpec {
    let fontSpec: FontSpec = {};
    if (strings.hasText(pattern)) {
      let tokens = pattern.split(/[-_,/.;]/);
      for (let i = 0; i < tokens.length; i++) {
        let token = tokens[i].toUpperCase();
        // styles
        if (token === 'NULL' || token === '0') {
          // nop (undefined values)
        } else if (token === 'PLAIN') {
          // nop
        } else if (token === 'BOLD') {
          fontSpec.bold = true;
        } else if (token === 'ITALIC') {
          fontSpec.italic = true;
        } else {
          // size or name
          if (/^\d+$/.test(token)) {
            fontSpec.size = parseInt(token);
          } else if (token !== 'NULL') {
            fontSpec.name = tokens[i];
          }
        }
      }
    }
    return fontSpec;
  },

  modelToCssColor(color: string): string {
    if (!color) { // prevent conversion from null to 'null' by regex
      return '';
    }
    let cssColor = '';
    if (/^[A-Fa-f0-9]{3}([A-Fa-f0-9]{3})?$/.test(color)) { // hex color
      cssColor = '#' + color;
    } else if (/^[A-Za-z0-9().,%-]+$/.test(color)) { // named colors or color functions
      cssColor = color;
    }
    return cssColor;
  },

  /**
   * Returns a string with CSS definitions for use in an element's "style" attribute. All CSS relevant
   * properties of the given object are converted to CSS definitions, namely foreground color, background
   * color and font.
   *
   * If an $element is provided, the CSS definitions are directly applied to the element. This can be
   * useful if the "style" attribute is shared and cannot be replaced in its entirety.
   *
   * If propertyPrefix is provided, the prefix will be applied to the properties, e.g. if the prefix is
   * 'label' the properties labelFont, labelBackgroundColor and labelForegroundColor are used instead of
   * just font, backgroundColor and foregroundColor.
   */
  legacyStyle(obj: object, $element?: JQuery, propertyPrefix?: string): string {
    let style = '';
    style += styles.legacyForegroundColor(obj, $element, propertyPrefix);
    style += styles.legacyBackgroundColor(obj, $element, propertyPrefix);
    style += styles.legacyFont(obj, $element, propertyPrefix);
    return style;
  },

  legacyForegroundColor(obj: object, $element?: JQuery, propertyPrefix?: string): string {
    propertyPrefix = propertyPrefix || '';

    let cssColor = '';
    if (obj) {
      let foregroundColorProperty = strings.toLowerCaseFirstLetter(propertyPrefix + 'ForegroundColor');
      cssColor = styles.modelToCssColor(obj[foregroundColorProperty]);
    }
    if ($element) {
      $element.css('color', cssColor);
    }
    let style = '';
    if (cssColor) {
      style += 'color: ' + cssColor + '; ';
    }
    return style;
  },

  legacyBackgroundColor(obj: object, $element?: JQuery, propertyPrefix?: string): string {
    propertyPrefix = propertyPrefix || '';

    let cssBackgroundColor = '';
    if (obj) {
      let backgroundColorProperty = strings.toLowerCaseFirstLetter(propertyPrefix + 'BackgroundColor');
      cssBackgroundColor = styles.modelToCssColor(obj[backgroundColorProperty]);
    }
    if ($element) {
      $element.css('background-color', cssBackgroundColor);
    }
    let style = '';
    if (cssBackgroundColor) {
      style += 'background-color: ' + cssBackgroundColor + '; ';
    }
    return style;
  },

  legacyFont(obj: object, $element?: JQuery, propertyPrefix?: string): string {
    propertyPrefix = propertyPrefix || '';

    let cssFontWeight = '';
    let cssFontStyle = '';
    let cssFontSize = '';
    let cssFontFamily = '';
    if (obj) {
      let fontProperty = strings.toLowerCaseFirstLetter(propertyPrefix + 'Font');
      let fontSpec = styles.parseFontSpec(obj[fontProperty]);
      if (fontSpec.bold) {
        cssFontWeight = 'bold';
      }
      if (fontSpec.italic) {
        cssFontStyle = 'italic';
      }
      if (fontSpec.size) {
        cssFontSize = fontSpec.size + 'pt';
      }
      if (fontSpec.name) {
        cssFontFamily = fontSpec.name;
      }
    }
    if ($element) {
      $element
        .css('font-weight', cssFontWeight)
        .css('font-style', cssFontStyle)
        .css('font-size', cssFontSize)
        .css('font-family', cssFontFamily);
    }
    let style = '';
    if (cssFontWeight) {
      style += 'font-weight: ' + cssFontWeight + '; ';
    }
    if (cssFontStyle) {
      style += 'font-style: ' + cssFontStyle + '; ';
    }
    if (cssFontSize) {
      style += 'font-size: ' + cssFontSize + '; ';
    }
    if (cssFontFamily) {
      style += 'font-family: ' + cssFontFamily + '; ';
    }
    return style;
  },

  /**
   * Adds the css classes to the existing css classes.
   *
   * @param cssClass existing cssClass, multiple css classes separated by space.
   * @param cssClassToAdd may contain multiple css classes separated by space.
   */
  addCssClass(cssClass: string, cssClassToAdd: string): string {
    const cssClasses = styles.cssClassAsArray(cssClass);
    const cssClassesToAdd = styles.cssClassAsArray(cssClassToAdd);

    for (const cssClassToAdd of cssClassesToAdd) {
      arrays.pushSet(cssClasses, cssClassToAdd);
    }

    return arrays.format(cssClasses, ' ');
  },

  /**
   * Removes the css classes to the existing css classes.
   *
   * @param cssClass existing cssClass, multiple css classes separated by space.
   * @param cssClassToRemove may contain multiple css classes separated by space.
   */
  removeCssClass(cssClass: string, cssClassToRemove: string): string {
    const cssClasses = styles.cssClassAsArray(cssClass);
    const cssClassesToRemove = styles.cssClassAsArray(cssClassToRemove);

    arrays.removeAll(cssClasses, cssClassesToRemove);

    return arrays.format(cssClasses, ' ');
  },

  /**
   * Toggles, i.e. adds (see {@link styles#addCssClass}) or removes (see {@link styles#removeCssClass}), the css classes depending on the condition.
   *
   * @param cssClass existing cssClass, multiple css classes separated by space.
   * @param cssClassToToggle may contain multiple css classes separated by space.
   */
  toggleCssClass(cssClass: string, cssClassToToggle: string, condition: boolean): string {
    return condition
      ? styles.addCssClass(cssClass, cssClassToToggle)
      : styles.removeCssClass(cssClass, cssClassToToggle);
  },

  /**
   * Checks whether the css classes are contained in the existing css classes.
   *
   * @param cssClass existing cssClass, multiple css classes separated by space.
   * @param cssClassToFind may contain multiple css classes separated by space.
   */
  hasCssClass(cssClass: string, cssClassToFind: string): boolean {
    const cssClasses = styles.cssClassAsArray(cssClass);
    const cssClassesToFind = styles.cssClassAsArray(cssClassToFind);

    return arrays.containsAll(cssClasses, cssClassesToFind);
  },

  /**
   * Splits the css classes into an array.
   *
   * @param cssClass multiple css classes separated by space.
   */
  cssClassAsArray(cssClass: string): string[] {
    cssClass ||= '';
    cssClass = cssClass.trim();

    if (!cssClass.length) {
      return [];
    }
    return cssClass.split(' ').filter(Boolean);
  },

  _getElement(): HTMLDivElement {
    return styles.element;
  }
};
