/**
 * @license
 * Copyright (c) 2017 - 2026 Vaadin Ltd.
 * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
 */
import { css, CSSResult, LitElement, unsafeCSS } from 'lit';
import { applyInstanceStyles } from './src/css-utils.js';
import { ThemePropertyMixin } from './vaadin-theme-property-mixin.js';

export { css, unsafeCSS };

/**
 * @typedef {Object} Theme
 * @property {string} themeFor
 * @property {CSSResult[]} styles
 * @property {string | string[]} [include]
 * @property {string} [moduleId]
 *
 * @typedef {CSSResult[] | CSSResult} CSSResultGroup
 */

/**
 * @type {Theme[]}
 */
const themeRegistry = [];

/**
 * @type {WeakRef<HTMLElement>[]}
 */
const themableInstances = new Set();

/**
 * @type {string[]}
 */
const themableTagNames = new Set();

/**
 * Check if the custom element type has themes applied.
 * @param {Function} elementClass
 * @returns {boolean}
 */
function classHasThemes(elementClass) {
  return elementClass && Object.prototype.hasOwnProperty.call(elementClass, '__themes');
}

/**
 * Check if the custom element type has themes applied.
 * @param {string} tagName
 * @returns {boolean}
 */
function hasThemes(tagName) {
  return classHasThemes(customElements.get(tagName));
}

/**
 * Flattens the styles into a single array of styles.
 * @param {CSSResultGroup} styles
 * @returns {CSSResult[]}
 */
function flattenStyles(styles = []) {
  return [styles].flat(Infinity).filter((style) => {
    if (style instanceof CSSResult) {
      return true;
    }
    console.warn('An item in styles is not of type CSSResult. Use `unsafeCSS` or `css`.');
    return false;
  });
}

/**
 * Returns true if the themeFor string matches the tag name
 * @param {string} themeFor
 * @param {string} tagName
 * @returns {boolean}
 */
function matchesThemeFor(themeFor, tagName) {
  return (themeFor || '').split(' ').some((themeForToken) => {
    return new RegExp(`^${themeForToken.split('*').join('.*')}$`, 'u').test(tagName);
  });
}

/**
 * Returns the CSS text content from an array of CSSResults
 * @param {CSSResult[]} styles
 * @returns {string}
 */
function getCssText(styles) {
  return styles.map((style) => style.cssText).join('\n');
}

const STYLE_ID = 'vaadin-themable-mixin-style';

/**
 * Includes the styles to the template.
 * @param {CSSResult[]} styles
 * @param {HTMLTemplateElement} template
 */
function addStylesToTemplate(styles, template) {
  const styleEl = document.createElement('style');
  styleEl.id = STYLE_ID;
  styleEl.textContent = getCssText(styles);
  template.content.appendChild(styleEl);
}

/**
 * Dynamically updates the styles of the given component instance.
 * @param {HTMLElement} instance
 */
function updateInstanceStyles(instance) {
  if (!instance.shadowRoot) {
    return;
  }

  const componentClass = instance.constructor;

  if (instance instanceof LitElement) {
    // LitElement
    applyInstanceStyles(instance);
  } else {
    // PolymerElement

    // Update style element content in the shadow root
    const style = instance.shadowRoot.getElementById(STYLE_ID);
    const template = componentClass.prototype._template;
    style.textContent = template.content.getElementById(STYLE_ID).textContent;
  }
}

/**
 * Dynamically updates the styles of the instances matching the given component type.
 * @param {Function} componentClass
 */
function updateInstanceStylesOfType(componentClass) {
  // Iterate over component instances and update their styles if needed
  themableInstances.forEach((ref) => {
    const instance = ref.deref();
    if (instance instanceof componentClass) {
      updateInstanceStyles(instance);
    } else if (!instance) {
      // Clean up the weak reference to a GC'd instance
      themableInstances.delete(ref);
    }
  });
}

/**
 * Dynamically updates the styles of the given component type.
 * @param {Function} componentClass
 */
function updateComponentStyles(componentClass) {
  if (componentClass.prototype instanceof LitElement) {
    // Update LitElement-based component's elementStyles
    componentClass.elementStyles = componentClass.finalizeStyles(componentClass.styles);
  } else {
    // Update Polymer-based component's template
    const template = componentClass.prototype._template;
    template.content.getElementById(STYLE_ID).textContent = getCssText(componentClass.getStylesForThis());
  }

  // Update the styles of inheriting types
  themableTagNames.forEach((inheritingTagName) => {
    const inheritingClass = customElements.get(inheritingTagName);
    if (inheritingClass !== componentClass && inheritingClass.prototype instanceof componentClass) {
      updateComponentStyles(inheritingClass);
    }
  });
}

/**
 * Check if the component type already has a style matching the given styles.
 *
 * @param {Function} componentClass
 * @param {CSSResultGroup} styles
 * @returns {boolean}
 */
function hasMatchingStyle(componentClass, styles) {
  const themes = componentClass.__themes;
  if (!themes || !styles) {
    return false;
  }

  return themes.some((theme) =>
    theme.styles.some((themeStyle) => styles.some((style) => style.cssText === themeStyle.cssText)),
  );
}

/**
 * Registers CSS styles for a component type. Make sure to register the styles before
 * the first instance of a component of the type is attached to DOM.
 *
 * @param {string} themeFor The local/tag name of the component type to register the styles for
 * @param {CSSResultGroup} styles The CSS style rules to be registered for the component type
 * matching themeFor and included in the local scope of each component instance
 * @param {{moduleId?: string, include?: string | string[]}} options Additional options
 * @return {void}
 */
export function registerStyles(themeFor, styles, options = {}) {
  styles = flattenStyles(styles);

  themeRegistry.push({
    themeFor,
    styles,
    include: options.include,
    moduleId: options.moduleId,
  });

  if (themeFor) {
    // Update styles of the component types that match themeFor and have already been finalized
    themableTagNames.forEach((tagName) => {
      if (matchesThemeFor(themeFor, tagName) && hasThemes(tagName)) {
        const componentClass = customElements.get(tagName);

        if (hasMatchingStyle(componentClass, styles)) {
          // Show a warning if the component type already has some of the given styles
          console.warn(`Registering styles that already exist for ${tagName}`);
        } else if (!window.Vaadin || !window.Vaadin.suppressPostFinalizeStylesWarning) {
          // Show a warning if the component type has already been finalized
          console.warn(
            `The custom element definition for "${tagName}" ` +
              `was finalized before a style module was registered. ` +
              `Ideally, import component specific style modules before ` +
              `importing the corresponding custom element. ` +
              `This warning can be suppressed by setting "window.Vaadin.suppressPostFinalizeStylesWarning = true".`,
          );
        }

        // Update the styles of the component type
        updateComponentStyles(componentClass);
        // Update the styles of the component instances matching the component type
        updateInstanceStylesOfType(componentClass);
      }
    });
  }
}

/**
 * Maps the moduleName to an include priority number which is used for
 * determining the order in which styles are applied.
 * @param {string} moduleName
 * @returns {number}
 */
function getIncludePriority(moduleName = '') {
  let includePriority = 0;
  if (moduleName.startsWith('lumo-') || moduleName.startsWith('material-')) {
    includePriority = 1;
  } else if (moduleName.startsWith('vaadin-')) {
    includePriority = 2;
  }
  return includePriority;
}

/**
 * Gets an array of CSSResults matching the include property of the theme.
 * @param {Theme} theme
 * @returns {CSSResult[]}
 */
function getIncludedStyles(theme) {
  const includedStyles = [];
  if (theme.include) {
    [].concat(theme.include).forEach((includeModuleId) => {
      const includedTheme = themeRegistry.find((s) => s.moduleId === includeModuleId);
      if (includedTheme) {
        includedStyles.push(...getIncludedStyles(includedTheme), ...includedTheme.styles);
      } else {
        console.warn(`Included moduleId ${includeModuleId} not found in style registry`);
      }
    }, theme.styles);
  }
  return includedStyles;
}

/**
 * Returns an array of themes that should be used for styling a component matching
 * the tag name. The array is sorted by the include order.
 * @param {string} tagName
 * @returns {Theme[]}
 */
function getThemes(tagName) {
  const defaultModuleName = `${tagName}-default-theme`;

  const themes = themeRegistry
    // Filter by matching themeFor properties
    .filter((theme) => theme.moduleId !== defaultModuleName && matchesThemeFor(theme.themeFor, tagName))
    .map((theme) => ({
      ...theme,
      // Prepend styles from included themes
      styles: [...getIncludedStyles(theme), ...theme.styles],
      // Map moduleId to includePriority
      includePriority: getIncludePriority(theme.moduleId),
    }))
    // Sort by includePriority
    .sort((themeA, themeB) => themeB.includePriority - themeA.includePriority);

  if (themes.length > 0) {
    return themes;
  }
  // No theme modules found, return the default module if it exists
  return themeRegistry.filter((theme) => theme.moduleId === defaultModuleName);
}

/**
 * @polymerMixin
 * @mixes ThemePropertyMixin
 */
export const ThemableMixin = (superClass) =>
  class VaadinThemableMixin extends ThemePropertyMixin(superClass) {
    constructor() {
      super();
      // Store a weak reference to the instance
      themableInstances.add(new WeakRef(this));
    }

    /**
     * Covers PolymerElement based component styling
     * @protected
     */
    static finalize() {
      super.finalize();

      if (this.is) {
        themableTagNames.add(this.is);
      }

      // Make sure not to run the logic intended for PolymerElement when LitElement is used.
      if (this.elementStyles) {
        return;
      }

      const template = this.prototype._template;
      if (!template || classHasThemes(this)) {
        return;
      }

      addStylesToTemplate(this.getStylesForThis(), template);
    }

    /**
     * Covers LitElement based component styling
     *
     * @protected
     */
    static finalizeStyles(styles) {
      // Preserve the styles the user supplied via the `static get styles()` getter
      // so that they will always be injected before styles added by `LumoInjector`.
      this.baseStyles = styles ? [styles].flat(Infinity) : [];

      // Preserve the theme styles the user supplied via the `registerStyles()` API
      // so that they will always be injected after styles added by `LumoInjector`.
      this.themeStyles = this.getStylesForThis();

      // Merged styles are stored in `elementStyles` and passed to `adoptStyles()`.
      return [...this.baseStyles, ...this.themeStyles];
    }

    /**
     * Get styles for the component type
     *
     * @private
     */
    static getStylesForThis() {
      const superClassThemes = superClass.__themes || [];
      const parent = Object.getPrototypeOf(this.prototype);
      const inheritedThemes = (parent ? parent.constructor.__themes : []) || [];
      this.__themes = [...superClassThemes, ...inheritedThemes, ...getThemes(this.is)];
      const themeStyles = this.__themes.flatMap((theme) => theme.styles);
      // Remove duplicates
      return themeStyles.filter((style, index) => index === themeStyles.lastIndexOf(style));
    }
  };
