/* eslint-disable no-param-reassign */
import { compose } from '../helpers';
import { forwardAttrs, getComponentName } from '../helpers/componentHelpers';
import { createDispatchEvent } from '../helpers/mixinsHelpers';
import { createStyleMixin } from './createStyleMixin';
import { inputValidationMixin } from './inputValidationMixin';

const errorAttrs = ['invalid', 'required'];

const forwardProps = (src, targets, property) => {
  const targetArr = Array.isArray(targets) ? targets : [targets];

  Object.defineProperty(src, property, {
    set(v) {
      targetArr.forEach((target) => {
        target[property] = v;
      });
    },
    get() {
      return targets[0][property];
    },
    configurable: true,
  });
};

// recursively take the first slot child until it finds an element which is not a slot
// stops after "nestingLevel" times
const getNestedInput = (ele) => {
  if (!ele) return undefined;

  const nestingLevel = 10;

  let nonSlotEle = ele;
  for (let i = 0; i < nestingLevel; i++) {
    [nonSlotEle] = nonSlotEle.assignedElements();
    if (nonSlotEle?.localName !== 'slot') return nonSlotEle;
  }
  return undefined;
};

const proxyInputMixin =
  ({
    proxyProps = [],
    // useProxyTargets flag allows to forwardProps to other targets, other than
    // `this.inputElement`. It's to be used within `external-input` components,
    // if needed
    useProxyTargets = false,
    // allows us to set the event that should trigger validation
    // it can be either a string or an array of strings (for multiple events)
    inputEvent = 'input',
    triggerValidationEvents = [],
    // Proxies all validations from the parent component to the input element
    proxyParentValidation = false,
  }) =>
  (superclass) =>
    class ProxyInputMixinClass extends inputValidationMixin(superclass) {
      static get observedAttributes() {
        return [...(superclass.observedAttributes || []), ...errorAttrs];
      }

      #inputElement;

      #dispatchChange = createDispatchEvent.bind(this, 'change');

      constructor() {
        super();

        this.#inputElement = super.inputElement;
      }

      // the web-component does not loaded immediately, so we want to defer the warning
      // and show it only if the input element was not found after the component is loaded
      warnIfInputElementIsMissing() {
        clearTimeout(this.inputElementTimerId);

        this.inputElementTimerId = setTimeout(() => {
          // eslint-disable-next-line no-console
          !this.#inputElement && console.warn(this.localName, 'no input was found');
        }, 0);
      }

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

        this.warnIfInputElementIsMissing();

        const inputSlot = this.baseElement.shadowRoot?.querySelector('slot[name="input"]');
        const textAreaSlot = this.baseElement.shadowRoot?.querySelector('slot[name="textarea"]');

        this.#inputElement = getNestedInput(inputSlot) || getNestedInput(textAreaSlot);

        return this.#inputElement;
      }

      set inputElement(ele) {
        this.#inputElement = ele;
      }

      getValidity() {
        return this.inputElement?.validity || {};
      }

      #handleErrorMessage() {
        this.setAttribute('error-message', this.validationMessage);
      }

      // We do not want to show the default validity report tooltip
      // So we are overriding the reportValidity fn to show the input's error message
      reportValidity = () => {
        // we want to update validity so in case the value was set programmatically, we won't get an error
        this.inputElement.setCustomValidity('');
        this.setCustomValidity('');
        if (!this.checkValidity()) {
          this.setAttribute('invalid', 'true');
          this.#handleErrorMessage();
          this.focus();
        }
      };

      // we are keeping also the default reportValidity because there are some components that still using it
      defaultReportValidity() {
        return super.reportValidity();
      }

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

        // vaadin sets invalid indication on blur
        // we want invalid indication to appear only when calling reportValidity
        // this is why we are overriding vaadin's setInvalid behavior
        // to update only when the element is valid (so invalid indication will only be removed)
        // and we are adding it in reportValidity
        // eslint-disable-next-line func-names
        this.baseElement._setInvalid = function (invalid) {
          if (!invalid && this._shouldSetInvalid(invalid)) {
            this.invalid = invalid;
          }
        };

        // We want to ensure that `input` events properly cross ShadowDOM boundries
        // e.g. When we autofill a component's input with 1Password, for instance,
        // the event is fired without `composed: true` and doesn't our component's
        // ShadowDOM.
        this.baseElement.addEventListener('input', (e) => {
          if (!e.composed) {
            this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
          }
        });

        triggerValidationEvents.forEach((e) => {
          this.baseElement?.addEventListener(e, () => {
            this.inputElement?.setCustomValidity('');
          });
        });

        // on some cases the base element is not ready so the inputElement is empty
        // we are deferring this section to make sure the base element is ready
        setTimeout(() => {
          const validationEvents = Array.isArray(inputEvent) ? inputEvent : [inputEvent];

          validationEvents.forEach((e) => {
            this.baseElement?.addEventListener(e, () => {
              if (!this.baseElement.checkValidity()) {
                this.#handleErrorMessage();
              } else {
                this.removeAttribute('invalid');
              }
            });
          });

          this.baseElement.addEventListener('change', () => {
            this.#dispatchChange();
          });

          this.addEventListener('invalid', () => {
            if (!this.checkValidity()) {
              this.setAttribute('invalid', 'true');
            }
            this.#handleErrorMessage();
          });

          // sync properties
          proxyProps.forEach((prop) => {
            const externalInput = this.querySelector('input[slot="external-input"]') || null;
            const proxyTargets = useProxyTargets
              ? [this.baseElement, externalInput].filter(Boolean)
              : [];
            forwardProps(this, [this.inputElement, ...proxyTargets], prop);
          });

          this.setSelectionRange = this.inputElement?.setSelectionRange?.bind(this.inputElement);

          forwardAttrs(this, this.inputElement, { includeAttrs: ['inputmode'] });
        });

        if (proxyParentValidation) {
          // All functions called on the inputElement internals will be applied to the parent
          // component internals as well. As a result, there's no need to add outer mechanisms
          // to update the parent component's validity state based on the input elements validity.
          const inputElementInternals = this.inputElement.internals;
          const parentThis = this;
          this.inputElement.internals = new Proxy(inputElementInternals, {
            get: (target, prop) => {
              if (typeof target[prop] === 'function' && prop === 'setValidity') {
                return (...args) => {
                  // If we're calling setValidity with 3 args, then the validationTarget
                  // needs to be swapped to be the inputElement
                  if (args.length === 3) {
                    const newArgs = args.slice(0, args.length - 1);
                    newArgs.push(parentThis.inputElement);
                    parentThis.internals[prop](...newArgs);
                  } else {
                    parentThis.internals[prop](...args);
                  }
                  return target[prop](...args);
                };
              }

              if (typeof target[prop] === 'function') {
                return (...args) => {
                  parentThis.internals[prop](...args);
                  return target[prop](...args);
                };
              }

              return target[prop];
            },
          });
        }
      }
    };

const composedProxyInputMixin = (proxyInputMixinConfig) =>
  compose(
    proxyInputMixin(proxyInputMixinConfig),
    // in order to use input-wrapper across all our input components, we need to inject its theme
    // to every proxy input mixin, to allow its css vars to be scoped properly and accessible
    // in the proxy input component
    createStyleMixin({ componentNameOverride: getComponentName('input-wrapper') })
  );

export { composedProxyInputMixin as proxyInputMixin };
