import { compose, compareArraysUnordered } from '../../helpers';
import {
  forwardAttrs,
  getComponentName,
  observeAttributes,
  observeChildren,
} from '../../helpers/componentHelpers';
import {
  resetInputLabelPosition,
  resetInputCursor,
  resetInputPlaceholder,
  resetInputReadonlyStyle,
  useHostExternalPadding,
  inputFloatingLabelStyle,
} from '../../helpers/themeHelpers/resetHelpers';
import {
  createStyleMixin,
  draggableMixin,
  createProxy,
  componentNameValidationMixin,
  portalMixin,
  proxyInputMixin,
  changeMixin,
} from '../../mixins';

export const componentName = getComponentName('multi-select-combo-box');

const multiSelectComboBoxMixin = (superclass) =>
  class MultiSelectComboBoxMixinClass extends superclass {
    static get observedAttributes() {
      return ['readonly'];
    }

    #renderItem = ({ displayName, value, label }) => {
      const ele = document.createElement('span');
      ele.setAttribute('data-name', label);
      ele.setAttribute('data-id', value);
      ele.textContent = displayName || label;

      return ele.outerHTML;
    };

    #data;

    #value = [];

    get defaultValues() {
      const defaultValuesAttr = this.getAttribute('default-values');
      if (defaultValuesAttr) {
        try {
          const defaultValues = JSON.parse(defaultValuesAttr);
          if (this.isValidDataType(defaultValues)) {
            return defaultValues;
          }
        } catch (e) {
          // eslint-disable-next-line no-console
          console.error('could not parse data string from attribute "default-values" -', e.message);
        }
      }
      return [];
    }

    get renderItem() {
      return this.#renderItem;
    }

    set renderItem(renderFn) {
      this.#renderItem = renderFn;
      this.renderItems();
    }

    get data() {
      if (this.#data) return this.#data;

      const dataAttr = this.getAttribute('data');

      if (dataAttr) {
        try {
          const data = JSON.parse(dataAttr);
          if (this.isValidDataType(data)) {
            return data;
          }
        } catch (e) {
          // eslint-disable-next-line no-console
          console.error('could not parse data string from attribute "data" -', e.message);
        }
      }

      return [];
    }

    set data(data) {
      if (this.isValidDataType(data)) {
        this.#data = data;
        this.renderItems();
      }
    }

    get allowCustomValues() {
      return this.getAttribute('allow-custom-value') === 'true';
    }

    get minItemsSelection() {
      return parseInt(this.getAttribute('min-items-selection'), 10) || 0;
    }

    get maxItemsSelection() {
      return parseInt(this.getAttribute('max-items-selection'), 10) || 0;
    }

    isValidDataType(data) {
      const isValid = Array.isArray(data);
      if (!isValid) {
        // eslint-disable-next-line no-console
        console.error('data and default-values must be an array, received:', data);
      }

      return isValid;
    }

    getItemsTemplate() {
      return this.data?.reduce?.((acc, item) => acc + (this.renderItem?.(item || {}) || ''), '');
    }

    renderItems() {
      const template = this.getItemsTemplate();
      if (template) this.innerHTML = template;
    }

    updateSelectedItems() {
      // This is a list of all the selected items, including ones that may have been removed from the DOM
      const currentSelected =
        this.baseElement.selectedItems?.map((item) => item.getAttribute('data-id')) || [];

      // if there are selected items, we want to trigger a potential update to the value if some child elements were removed
      if (currentSelected.length > 0) {
        // setting the value checks that the selected items are still in the DOM and will not set the value if they are not
        this.value = currentSelected;
      }

      // otherwise, if default value is specified, set default value as selected item
      if (this.value.length === 0) {
        this.setDefaultValues();
      }
    }

    customValueTransformFn(val) {
      return val;
    }

    // We want to override Vaadin's Combo Box value setter. This is needed since Vaadin couples between the
    // field that it searches the value, and the finaly display value of the input.
    // We provide a custom transform function to override that behavior.
    setComboBoxDescriptor() {
      const valueDescriptor = Object.getOwnPropertyDescriptor(
        this.inputElement.constructor.prototype,
        'value'
      );

      const comboBox = this;

      Object.defineProperties(this.inputElement, {
        value: {
          ...valueDescriptor,
          set(val) {
            const transformedValue = comboBox.customValueTransformFn(val) || '';

            if (transformedValue === this.value) {
              return;
            }

            valueDescriptor.set.call(this, transformedValue);
          },
        },
      });
    }

    // To prevent duplicate items for the multi select options, we dedup them based on the "data-id" attribute

    #dedupItems(items) {
      return Array.from(
        new Map(items.map((item) => [item.getAttribute('data-id'), item])).values()
      );
    }

    // vaadin api is to set props on their combo box node,
    // in order to avoid it, we are passing the children of this component
    // to the items & renderer props, so it will be used as the combo box items
    #onChildrenChange() {
      const items = Array.from(this.children);
      const dedupItems = this.#dedupItems(items);

      // we want the data-name attribute to be accessible as an object attribute
      if (items.length) {
        this.removeAttribute('has-no-options');

        dedupItems.forEach((node) => {
          Object.defineProperty(node, 'data-name', {
            value: node.getAttribute('data-name'),
            configurable: true,
            writable: true,
          });
          Object.defineProperty(node, 'data-id', {
            value: node.getAttribute('data-id'),
            configurable: true,
            writable: true,
          });
        });

        this.baseElement.items = dedupItems;

        setTimeout(() => {
          // set timeout to ensure this runs after customValueTransformFn had the chance to be overriden
          this.updateSelectedItems();
        }, 0);
      } else {
        this.baseElement.items = [];
        this.setAttribute('has-no-options', '');
      }

      // use vaadin combobox custom renderer to render options as HTML
      // and not via default renderer, which renders only the data-name's value
      // in its own HTML template
      this.baseElement.renderer = (root, combo, model) => {
        root.innerHTML = model.item.outerHTML;
      };
    }

    // the default vaadin behavior is to attach the overlay to the body when opened
    // we do not want that because it's difficult to style the overlay in this way
    // so we override it to open inside the shadow DOM
    #overrideOverlaySettings() {
      const overlay = this.baseElement.shadowRoot
        .querySelector('vaadin-multi-select-combo-box-internal')
        .shadowRoot.querySelector('vaadin-multi-select-combo-box-overlay');
      overlay._attachOverlay = () => {
        overlay.bringToFront();
      };
      overlay._detachOverlay = () => {};
      overlay._enterModalState = () => {};
    }

    #handleCustomValues() {
      if (this.allowCustomValues) {
        this.baseElement.addEventListener('custom-value-set', (e) => {
          const newItemHtml = this.#renderItem({
            label: e.detail,
            displayName: e.detail,
            value: e.detail,
          });
          this.innerHTML += newItemHtml;
          // The internal filter needs to be removed, otherwise there's a bug where a new custom item
          // added can't be removed from the dropdown because of how the vaadin component is implemented
          this.baseElement._lastFilter = '';
          // The value needs to be set with a timeout because it needs to execute after
          // the custom value is added to items by the children change observer
          setTimeout(() => {
            this.value = [...this.value, e.detail];
          }, 0);
        });
      }
    }

    setGetValidity() {
      // eslint-disable-next-line func-names
      this.getValidity = function () {
        if (this.pattern) {
          const patternRegex = new RegExp(this.pattern);
          if (this.value.some((val) => !patternRegex.test(val)))
            return {
              patternMismatch: true,
            };
        }

        if (this.isRequired && !this.value.length) {
          return {
            valueMissing: true,
          };
        }
        // If the field is not required, no minimum selection can be set
        if (
          this.isRequired &&
          this.minItemsSelection &&
          this.value.length < this.minItemsSelection
        ) {
          return {
            rangeUnderflow: true,
          };
        }
        if (this.maxItemsSelection && this.value.length > this.maxItemsSelection) {
          return {
            rangeOverflow: true,
          };
        }
        return {};
      };

      // This is required to override the default validity check of the vaadin component
      // which is triggered when the component is checked for validity after blur
      // Without this, our minItemsSelection and maxItemsSelection constraints will not be checked
      const that = this;

      this.baseElement.checkValidity = () => that.validity.valid;
    }

    init() {
      super.init?.();

      this.setGetValidity();

      this.#handleCustomValues();

      this.renderItems();

      observeAttributes(this, this.renderItems.bind(this), { includeAttrs: ['data'] });

      observeChildren(this, this.#onChildrenChange.bind(this));

      // Note: we need to forward the `placeholder` because the vaadin component observes it and
      // tries to override it, causing us to lose the user set placeholder.
      forwardAttrs(this, this.baseElement, { includeAttrs: ['placeholder'] });

      // This is a workaround for a problem we have in Console App, where in ScreenBuilder - the inputElement is not ready when trying to
      // set the component's descriptor and override the overlay settings.
      // THIS IS A PROBLEM WITH THE CONSOLE APP THAT NEEDS TO BE RESOLVED. Until then, we use this workaround.
      setTimeout(() => {
        this.setComboBoxDescriptor();
        this.#overrideOverlaySettings();
      });

      this.setDefaultValues();

      this.baseElement.addEventListener('selected-items-changed', () => {
        this.#updateInternalValue();
        this.dispatchEvent(new CustomEvent('input', { bubbles: true }));
      });
    }

    setDefaultValues() {
      const initialDefaultValues = this.defaultValues;
      if (initialDefaultValues.length > 0) {
        this.value = this.defaultValues;
      }
    }

    #updateInternalValue() {
      // This is done here because we don't want to return a different copy of the same array
      // every time get value is called if a new value wasn't set
      this.#value =
        this.baseElement.selectedItems?.map((elem) => elem.getAttribute('data-id')) || [];
    }

    // Updating the value will update the selectedItems, which will trigger an event 'selected-items-changed'
    // which we listen to in the init function to update the internal value
    set value(vals) {
      if (vals && vals.length > 0) {
        // Filters the children of the component to find the ones that match the values,
        // since it's possible that some values that are trying to set are not in the children
        const selectedChildren = this.baseElement.items?.filter((item) =>
          vals.includes(item['data-id'])
        );

        // If the component allows custom values, we need to add the values that are not present in the children
        if (this.allowCustomValues) {
          const existingValues =
            selectedChildren?.map((child) => child.getAttribute('data-id')) || [];
          const missingValues = vals.filter((val) => !existingValues.includes(val));

          if (missingValues.length) {
            const newItemsHtml = missingValues.reduce((acc, val) => {
              const newItemHtml = this.#renderItem({
                label: val,
                displayName: val,
                value: val,
              });
              return acc + newItemHtml;
            }, '');
            this.innerHTML += newItemsHtml;

            // The value needs to be set with a timeout because it needs to execute after
            // the custom values are added to the items by the children change observer
            setTimeout(() => {
              this.value = vals;
            }, 0);
            return;
          }
        }

        const newSelectedValues =
          selectedChildren?.map((child) => child.getAttribute('data-id')) || [];
        if (!compareArraysUnordered(this.#value, newSelectedValues)) {
          this.baseElement.selectedItems = selectedChildren;
        }
      } else {
        this.baseElement.selectedItems = [];
      }
    }

    get value() {
      return this.#value;
    }

    attributeChangedCallback(attrName, oldValue, newValue) {
      super.attributeChangedCallback?.(attrName, oldValue, newValue);

      if (attrName === 'readonly') {
        this.onReadOnlyChange(newValue !== null && newValue === 'true');
      }
    }

    onReadOnlyChange(val) {
      if (val) {
        this.baseElement?.shadowRoot
          ?.querySelector('vaadin-multi-select-combo-box-internal')
          ?.setAttribute('inert', val);
      } else {
        this.baseElement?.shadowRoot
          ?.querySelector('vaadin-multi-select-combo-box-internal')
          ?.removeAttribute('inert');
      }
    }
  };

const {
  host,
  inputField,
  inputElement,
  placeholder,
  toggle,
  clearButton,
  label,
  requiredIndicator,
  helperText,
  errorMessage,
  chip,
  chipLabel,
  overflowChipFirstBorder,
  overflowChipSecondBorder,
} = {
  host: { selector: () => ':host' },
  inputField: { selector: '::part(input-field)' },
  inputElement: { selector: 'input' },
  placeholder: { selector: '> input:placeholder-shown' },
  toggle: { selector: '::part(toggle-button)' },
  clearButton: { selector: '::part(clear-button)' },
  label: { selector: '::part(label)' },
  requiredIndicator: { selector: '[required]::part(required-indicator)::after' },
  helperText: { selector: '::part(helper-text)' },
  errorMessage: { selector: '::part(error-message)' },
  chip: { selector: 'vaadin-multi-select-combo-box-chip' },
  chipLabel: { selector: 'vaadin-multi-select-combo-box-chip::part(label)' },
  overflowChipFirstBorder: {
    selector: "vaadin-multi-select-combo-box-chip[slot='overflow']::before",
  },
  overflowChipSecondBorder: {
    selector: "vaadin-multi-select-combo-box-chip[slot='overflow']::after",
  },
};

export const MultiSelectComboBoxClass = compose(
  createStyleMixin({
    mappings: {
      hostWidth: { ...host, property: 'width' },
      hostDirection: { ...host, property: 'direction' },
      // we apply font-size also on the host so we can set its width with em
      fontSize: [{}, host],
      chipFontSize: { ...chipLabel, property: 'font-size' },
      fontFamily: [label, placeholder, inputField, helperText, errorMessage, chipLabel],
      labelFontSize: { ...label, property: 'font-size' },
      labelFontWeight: { ...label, property: 'font-weight' },
      labelTextColor: [
        { ...label, property: 'color' },
        { ...requiredIndicator, property: 'color' },
      ],
      errorMessageTextColor: { ...errorMessage, property: 'color' },
      errorMessageIcon: { ...errorMessage, property: 'background-image' },
      errorMessageIconSize: { ...errorMessage, property: 'background-size' },
      errorMessageIconPadding: { ...errorMessage, property: 'padding-inline-start' },
      errorMessageIconRepeat: { ...errorMessage, property: 'background-repeat' },
      errorMessageIconPosition: { ...errorMessage, property: 'background-position' },
      errorMessageFontSize: { ...errorMessage, property: 'font-size' },
      inputHeight: { ...inputField, property: 'min-height' },
      inputBackgroundColor: { ...inputField, property: 'background-color' },
      inputBorderColor: { ...inputField, property: 'border-color' },
      inputBorderWidth: { ...inputField, property: 'border-width' },
      inputBorderStyle: { ...inputField, property: 'border-style' },
      inputBorderRadius: { ...inputField, property: 'border-radius' },
      labelRequiredIndicator: { ...requiredIndicator, property: 'content' },
      inputValueTextColor: { ...inputField, property: 'color' },
      inputPlaceholderTextColor: { ...placeholder, property: 'color' },
      inputDropdownButtonCursor: [
        { ...toggle, property: 'cursor' },
        { ...clearButton, property: 'cursor' },
      ],
      inputDropdownButtonColor: [
        { ...toggle, property: 'color' },
        { ...clearButton, property: 'color' },
      ],
      inputDropdownButtonSize: [
        { ...toggle, property: 'font-size' },
        { ...clearButton, property: 'font-size' },
      ],
      inputDropdownButtonOffset: [
        { ...toggle, property: 'margin-right' },
        { ...toggle, property: 'margin-left' },
      ],
      inputOutlineColor: { ...inputField, property: 'outline-color' },
      inputOutlineWidth: { ...inputField, property: 'outline-width' },
      inputOutlineStyle: { ...inputField, property: 'outline-style' },
      inputOutlineOffset: { ...inputField, property: 'outline-offset' },
      inputHorizontalPadding: [
        { ...inputElement, property: 'padding-left' },
        { ...inputElement, property: 'padding-right' },
        { ...inputField, property: 'padding-inline-start' },
      ],
      inputVerticalPadding: [
        { ...inputField, property: 'padding-top' },
        { ...inputField, property: 'padding-bottom' },
      ],
      chipTextColor: { ...chipLabel, property: 'color' },
      chipBackgroundColor: [
        { ...chip, property: 'background-color' },
        { ...overflowChipFirstBorder, property: 'border-color' },
        { ...overflowChipSecondBorder, property: 'border-color' },
      ],

      labelPosition: { ...label, property: 'position' },
      labelTopPosition: { ...label, property: 'top' },
      labelLeftPosition: { ...label, property: 'left' },
      labelHorizontalPosition: [
        { ...label, property: 'left' },
        { ...label, property: 'right' },
      ],
      inputTransformY: { ...label, property: 'transform' },
      inputTransition: { ...label, property: 'transition' },
      marginInlineStart: { ...label, property: 'margin-inline-start' },
      placeholderOpacity: { ...placeholder, property: 'opacity' },
      inputVerticalAlignment: { ...inputField, property: 'align-items' },

      // we need to use the variables from the portal mixin
      // so we need to use an arrow function on the selector
      // for that to work, because ComboBox is not available
      // at this time.
      overlayBackground: {
        property: () => MultiSelectComboBoxClass.cssVarList.overlay.backgroundColor,
      },
      overlayTextColor: {
        property: () => MultiSelectComboBoxClass.cssVarList.overlay.textColor,
      },
      overlayBorder: { property: () => MultiSelectComboBoxClass.cssVarList.overlay.border },
      overlayFontSize: { property: () => MultiSelectComboBoxClass.cssVarList.overlay.fontSize },
      overlayFontFamily: { property: () => MultiSelectComboBoxClass.cssVarList.overlay.fontFamily },
      overlayCursor: { property: () => MultiSelectComboBoxClass.cssVarList.overlay.cursor },
      overlayItemBoxShadow: {
        property: () => MultiSelectComboBoxClass.cssVarList.overlay.itemBoxShadow,
      },
      overlayItemPaddingInlineStart: {
        property: () => MultiSelectComboBoxClass.cssVarList.overlay.itemPaddingInlineStart,
      },
      overlayItemPaddingInlineEnd: {
        property: () => MultiSelectComboBoxClass.cssVarList.overlay.itemPaddingInlineEnd,
      },
    },
  }),
  draggableMixin,
  portalMixin({
    name: 'overlay',
    selector: 'vaadin-multi-select-combo-box-internal',
    mappings: {
      backgroundColor: { selector: 'vaadin-multi-select-combo-box-scroller' },
      minHeight: { selector: 'vaadin-multi-select-combo-box-overlay' },
      margin: { selector: 'vaadin-multi-select-combo-box-overlay' },
      cursor: { selector: 'vaadin-multi-select-combo-box-item' },
      fontFamily: { selector: 'vaadin-multi-select-combo-box-item' },
      textColor: { selector: 'vaadin-multi-select-combo-box-item', property: 'color' },
      fontSize: { selector: 'vaadin-multi-select-combo-box-item' },
      itemBoxShadow: { selector: 'vaadin-multi-select-combo-box-item', property: 'box-shadow' },
      itemPaddingInlineStart: {
        selector: 'vaadin-multi-select-combo-box-item',
        property: 'padding-inline-start',
      },
      itemPaddingInlineEnd: {
        selector: 'vaadin-multi-select-combo-box-item',
        property: 'padding-inline-end',
      },
    },
    forward: {
      include: false,
      attributes: ['size'],
    },
  }),
  proxyInputMixin({ proxyProps: ['selectionStart'], inputEvent: 'selected-items-changed' }),
  changeMixin,
  componentNameValidationMixin,
  multiSelectComboBoxMixin
)(
  createProxy({
    slots: ['', 'prefix'],
    wrappedEleName: 'vaadin-multi-select-combo-box',
    style: () => `
		:host {
			display: inline-flex;
			box-sizing: border-box;
			-webkit-mask-image: none;
		}
		${useHostExternalPadding(MultiSelectComboBoxClass.cssVarList)}
		${resetInputReadonlyStyle('vaadin-multi-select-combo-box')}
		${resetInputPlaceholder('vaadin-multi-select-combo-box')}
		${resetInputCursor('vaadin-multi-select-combo-box')}

		vaadin-multi-select-combo-box {
			padding: 0;
			width: 100%;
		}
		vaadin-multi-select-combo-box::before {
			height: initial;
		}
		vaadin-multi-select-combo-box [slot="input"] {
			-webkit-mask-image: none;
			min-height: 0;
      align-self: center;
			box-sizing: border-box;
		}
		vaadin-multi-select-combo-box[readonly] [slot="input"] {
      flex-grow: 1;
      flex-basis: 4em;
		}

		::part(input-field) {
      padding: 0;
			box-shadow: none;
		}
    ${resetInputLabelPosition('vaadin-multi-select-combo-box')}
    :host([has-label]) vaadin-multi-select-combo-box-chip::part(label) {
      display: block;
    }

    vaadin-multi-select-combo-box vaadin-multi-select-combo-box-chip[slot='overflow']::before,
    vaadin-multi-select-combo-box vaadin-multi-select-combo-box-chip[slot='overflow']::after {
      left: -4px;
      right: -4px;
      border-left-width: 0;
      border-inline-start-style: solid;
      border-inline-start-width: 2px;
    }
    vaadin-multi-select-combo-box vaadin-multi-select-combo-box-chip[slot='overflow']::after {
      left: -8px;
      right: -8px;
    }

    :host([has-no-options][allow-custom-value='true']) ::part(toggle-button) {
      display: none;
    }

    ${inputFloatingLabelStyle()}

    vaadin-multi-select-combo-box::part(toggle-button),
    vaadin-multi-select-combo-box::part(clear-button) {
      align-self: center;
    }

    vaadin-multi-select-combo-box[label-type="floating"]:not([focused])[readonly] > input:placeholder-shown {
      opacity: 0;
    }
    vaadin-multi-select-combo-box[label-type="floating"]:not([focused])[disabled] > input:placeholder-shown {
      opacity: 0;
    }

		`,
    // Note: we exclude `size` to avoid overriding Vaadin's ComboBox property
    // with the same name. Including it will cause Vaadin to calculate NaN size,
    // and reset items to an empty array, and opening the list box with no items
    // to display.
    // Note: we exclude `placeholder` because the vaadin component observes it and
    // tries to override it, causing us to lose the user set placeholder.
    excludeAttrsSync: ['tabindex', 'size', 'data', 'placeholder', 'style'],
    componentName,
    includeForwardProps: ['items', 'renderer', 'selectedItems'],
  })
);
