/**
 * @license
 * Copyright (c) 2018 - 2026 Vaadin Ltd.
 * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
 */
import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js';
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js';
import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js';
import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js';

/**
 * A mixin providing common checkbox-group functionality.
 *
 * @polymerMixin
 * @mixes DisabledMixin
 * @mixes FieldMixin
 * @mixes FocusMixin
 */
export const CheckboxGroupMixin = (superclass) =>
  class CheckboxGroupMixinClass extends FieldMixin(FocusMixin(DisabledMixin(superclass))) {
    static get properties() {
      return {
        /**
         * An array containing values of the currently checked checkboxes.
         *
         * The array is immutable so toggling checkboxes always results in
         * creating a new array.
         */
        value: {
          type: Array,
          value: () => [],
          notify: true,
          sync: true,
          observer: '__valueChanged',
        },

        /**
         * When true, the user cannot modify the value of the checkbox group.
         * The difference between `disabled` and `readonly` is that in the
         * read-only checkbox group, all the checkboxes are also read-only,
         * and therefore remain focusable and announced by screen readers.
         */
        readonly: {
          type: Boolean,
          value: false,
          reflectToAttribute: true,
          observer: '__readonlyChanged',
        },
      };
    }

    constructor() {
      super();

      this.__registerCheckbox = this.__registerCheckbox.bind(this);
      this.__unregisterCheckbox = this.__unregisterCheckbox.bind(this);
      this.__onCheckboxCheckedChanged = this.__onCheckboxCheckedChanged.bind(this);

      this._tooltipController = new TooltipController(this);
      this._tooltipController.addEventListener('tooltip-changed', (event) => {
        const tooltip = event.detail.node;
        if (tooltip && tooltip.isConnected) {
          // Tooltip element has been added to the DOM
          const inputs = this.__checkboxes.map((checkbox) => checkbox.inputElement);
          this._tooltipController.setAriaTarget(inputs);
        } else {
          // Tooltip element is no longer connected
          this._tooltipController.setAriaTarget([]);
        }
      });
    }

    /**
     * A collection of the checkboxes.
     *
     * @return {!Array<!Checkbox>}
     * @private
     */
    get __checkboxes() {
      return this.__filterCheckboxes([...this.children]);
    }

    /** @protected */
    ready() {
      super.ready();

      this.ariaTarget = this;

      // See https://github.com/vaadin/vaadin-web-components/issues/94
      this.setAttribute('role', 'group');

      const slot = this.shadowRoot.querySelector('slot:not([name])');
      this._observer = new SlotObserver(slot, ({ addedNodes, removedNodes }) => {
        const addedCheckboxes = this.__filterCheckboxes(addedNodes);
        const removedCheckboxes = this.__filterCheckboxes(removedNodes);

        addedCheckboxes.forEach(this.__registerCheckbox);
        removedCheckboxes.forEach(this.__unregisterCheckbox);

        const inputs = this.__checkboxes.map((checkbox) => checkbox.inputElement);
        this._tooltipController.setAriaTarget(inputs);

        this.__warnOfCheckboxesWithoutValue(addedCheckboxes);
      });

      this.addController(this._tooltipController);
    }

    /**
     * Override method inherited from `ValidateMixin`
     * to validate the value array.
     *
     * @override
     * @return {boolean}
     */
    checkValidity() {
      return !this.required || Boolean(this.value && this.value.length > 0);
    }

    /**
     * @param {!Array<!Node>} nodes
     * @return {!Array<!Checkbox>}
     * @private
     */
    __filterCheckboxes(nodes) {
      return nodes.filter((node) => node.nodeType === Node.ELEMENT_NODE && node.localName === 'vaadin-checkbox');
    }

    /**
     * @param {!Array<!Checkbox>} checkboxes
     * @private
     */
    __warnOfCheckboxesWithoutValue(checkboxes) {
      const hasCheckboxesWithoutValue = checkboxes.some((checkbox) => {
        const { value } = checkbox;

        return !checkbox.hasAttribute('value') && (!value || value === 'on');
      });

      if (hasCheckboxesWithoutValue) {
        console.warn('Please provide the value attribute to all the checkboxes inside the checkbox group.');
      }
    }

    /**
     * Registers the checkbox after adding it to the group.
     *
     * @param {!Checkbox} checkbox
     * @private
     */
    __registerCheckbox(checkbox) {
      checkbox.addEventListener('checked-changed', this.__onCheckboxCheckedChanged);

      if (this.disabled) {
        checkbox.disabled = true;
      }

      if (this.readonly) {
        checkbox.readonly = true;
      }

      if (checkbox.checked) {
        this.__addCheckboxToValue(checkbox.value);
      } else if (this.value && this.value.includes(checkbox.value)) {
        checkbox.checked = true;
      }
    }

    /**
     * Unregisters the checkbox before removing it from the group.
     *
     * @param {!Checkbox} checkbox
     * @private
     */
    __unregisterCheckbox(checkbox) {
      checkbox.removeEventListener('checked-changed', this.__onCheckboxCheckedChanged);

      if (checkbox.checked) {
        this.__removeCheckboxFromValue(checkbox.value);
      }
    }

    /**
     * Override method inherited from `DisabledMixin`
     * to propagate the `disabled` property to the checkboxes.
     *
     * @param {boolean} newValue
     * @param {boolean} oldValue
     * @override
     * @protected
     */
    _disabledChanged(newValue, oldValue) {
      super._disabledChanged(newValue, oldValue);

      // Prevent updating the `disabled` property for the checkboxes at initialization.
      // Otherwise, the checkboxes may end up enabled regardless the `disabled` attribute
      // intentionally added by the user on some of them.
      if (!newValue && oldValue === undefined) {
        return;
      }

      if (oldValue !== newValue) {
        this.__checkboxes.forEach((checkbox) => {
          checkbox.disabled = newValue;
        });
      }
    }

    /**
     * @param {string} value
     * @private
     */
    __addCheckboxToValue(value) {
      if (!this.value) {
        this.value = [value];
      } else if (!this.value.includes(value)) {
        this.value = [...this.value, value];
      }
    }

    /**
     * @param {string} value
     * @private
     */
    __removeCheckboxFromValue(value) {
      if (this.value && this.value.includes(value)) {
        this.value = this.value.filter((v) => v !== value);
      }
    }

    /**
     * @param {!CustomEvent} event
     * @private
     */
    __onCheckboxCheckedChanged(event) {
      const checkbox = event.target;

      if (checkbox.checked) {
        this.__addCheckboxToValue(checkbox.value);
      } else {
        this.__removeCheckboxFromValue(checkbox.value);
      }
    }

    /**
     * @param {string[] | null | undefined} value
     * @param {string[] | null | undefined} oldValue
     * @private
     */
    __valueChanged(value, oldValue) {
      // Setting initial value to empty array, skip validation
      if (value && value.length === 0 && oldValue === undefined) {
        return;
      }

      this.toggleAttribute('has-value', value && value.length > 0);

      this.__checkboxes.forEach((checkbox) => {
        checkbox.checked = value && value.includes(checkbox.value);
      });

      if (oldValue !== undefined) {
        this._requestValidation();
      }
    }

    /** @private */
    __readonlyChanged(readonly, oldReadonly) {
      if (readonly || oldReadonly) {
        this.__checkboxes.forEach((checkbox) => {
          checkbox.readonly = readonly;
        });
      }
    }

    /**
     * Override method inherited from `FocusMixin`
     * to prevent removing the `focused` attribute
     * when focus moves between checkboxes inside the group.
     *
     * @param {!FocusEvent} event
     * @return {boolean}
     * @protected
     */
    _shouldRemoveFocus(event) {
      return !this.contains(event.relatedTarget);
    }

    /**
     * Override method inherited from `FocusMixin`
     * to run validation when the group loses focus.
     *
     * @param {boolean} focused
     * @override
     * @protected
     */
    _setFocused(focused) {
      super._setFocused(focused);

      // Do not validate when focusout is caused by document
      // losing focus, which happens on browser tab switch.
      if (!focused && document.hasFocus()) {
        this._requestValidation();
      }
    }
  };
