/**
 * @license
 * Copyright 2018 Google Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

import { MDCComponent } from '@smui/common/base/component';
import type { SpecificEventListener } from '@smui/common/base/types';
import { closest, matches } from '@smui/common/dom/ponyfill';

import type { MDCListAdapter } from './adapter';
import {
  cssClasses,
  deprecatedClassNameMap,
  evolutionAttribute,
  evolutionClassNameMap,
  numbers,
  strings,
} from './constants';
import { MDCListFoundation } from './foundation';
import type {
  MDCListActionEventDetail,
  MDCListIndex,
  MDCListSelectionChangeDetail,
} from './types';

/** MDC List Factory */
export type MDCListFactory = (
  el: HTMLElement,
  foundation?: MDCListFoundation,
) => MDCList;

/** MDC List */
export class MDCList extends MDCComponent<MDCListFoundation> {
  set vertical(value: boolean) {
    this.foundation.setVerticalOrientation(value);
  }

  get listElements() {
    return Array.from(
      this.root.querySelectorAll<HTMLElement>(
        `.${this.classNameMap[cssClasses.LIST_ITEM_CLASS]}`,
      ),
    );
  }

  set wrapFocus(value: boolean) {
    this.foundation.setWrapFocus(value);
  }

  /**
   * @return Whether typeahead is currently matching a user-specified prefix.
   */
  get typeaheadInProgress(): boolean {
    return this.foundation.isTypeaheadInProgress();
  }

  /**
   * Sets whether typeahead functionality is enabled on the list.
   * @param hasTypeahead Whether typeahead is enabled.
   */
  set hasTypeahead(hasTypeahead: boolean) {
    this.foundation.setHasTypeahead(hasTypeahead);
  }

  set singleSelection(isSingleSelectionList: boolean) {
    this.foundation.setSingleSelection(isSingleSelectionList);
  }

  set disabledItemsFocusable(areDisabledItemsFocusable: boolean) {
    this.foundation.setDisabledItemsFocusable(areDisabledItemsFocusable);
  }

  get selectedIndex(): MDCListIndex {
    return this.foundation.getSelectedIndex();
  }

  set selectedIndex(index: MDCListIndex) {
    this.foundation.setSelectedIndex(index);
  }

  static override attachTo(root: HTMLElement) {
    return new MDCList(root);
  }

  // The follow are assigned in initialSyncWithDOM().
  private handleKeydown!: SpecificEventListener<'keydown'>;
  private handleClick!: SpecificEventListener<'click'>;
  private focusInEventListener!: SpecificEventListener<'focus'>;
  private focusOutEventListener!: SpecificEventListener<'focus'>;

  // This mapping provides a layer of indirection from legacy classes to
  // evolution classes, since there are some inconsistencies between the
  // two.
  // TODO(b/176814973): remove this map when evolution is launched.
  private classNameMap!: { [className: string]: string };
  private isEvolutionEnabled!: boolean;
  private isInteractive!: boolean;

  override initialSyncWithDOM() {
    this.isEvolutionEnabled = evolutionAttribute in this.root.dataset;

    if (this.isEvolutionEnabled) {
      this.classNameMap = evolutionClassNameMap;
    } else if (matches(this.root, strings.DEPRECATED_SELECTOR)) {
      this.classNameMap = deprecatedClassNameMap;
    } else {
      this.classNameMap = Object.values(cssClasses).reduce(
        (obj: { [className: string]: string }, className) => {
          obj[className] = className;
          return obj;
        },
        {},
      );
    }

    this.handleClick = this.handleClickEvent.bind(this);
    this.handleKeydown = this.handleKeydownEvent.bind(this);
    this.focusInEventListener = this.handleFocusInEvent.bind(this);
    this.focusOutEventListener = this.handleFocusOutEvent.bind(this);
    this.listen('keydown', this.handleKeydown);
    this.listen('click', this.handleClick);
    this.listen('focusin', this.focusInEventListener);
    this.listen('focusout', this.focusOutEventListener);
    this.layout();
    this.initializeListType();
    this.ensureFocusable();
  }

  override destroy() {
    this.unlisten('keydown', this.handleKeydown);
    this.unlisten('click', this.handleClick);
    this.unlisten('focusin', this.focusInEventListener);
    this.unlisten('focusout', this.focusOutEventListener);
  }

  layout() {
    const direction = this.root.getAttribute(strings.ARIA_ORIENTATION);
    this.vertical = direction !== strings.ARIA_ORIENTATION_HORIZONTAL;

    const itemSelector = `.${this.classNameMap[cssClasses.LIST_ITEM_CLASS]}:not([tabindex])`;
    const childSelector = strings.FOCUSABLE_CHILD_ELEMENTS;

    // List items need to have at least tabindex=-1 to be focusable.
    const itemEls = this.root.querySelectorAll<HTMLElement>(itemSelector);
    if (itemEls.length) {
      Array.prototype.forEach.call(itemEls, (el: Element) => {
        el.setAttribute('tabindex', '-1');
      });
    }

    // Child button/a elements are not tabbable until the list item is focused.
    const focusableChildEls =
      this.root.querySelectorAll<HTMLElement>(childSelector);
    if (focusableChildEls.length) {
      Array.prototype.forEach.call(focusableChildEls, (el: Element) => {
        el.setAttribute('tabindex', '-1');
      });
    }

    if (this.isEvolutionEnabled) {
      this.foundation.setUseSelectedAttribute(true);
    }
    this.foundation.layout();
  }

  /**
   * Extracts the primary text from a list item.
   * @param item The list item element.
   * @return The primary text in the element.
   */
  getPrimaryText(item: Element): string {
    const primaryText = item.querySelector<HTMLElement>(
      `.${this.classNameMap[cssClasses.LIST_ITEM_PRIMARY_TEXT_CLASS]}`,
    );
    if (this.isEvolutionEnabled || primaryText) {
      return primaryText?.textContent ?? '';
    }

    const singleLineText = item.querySelector<HTMLElement>(
      `.${this.classNameMap[cssClasses.LIST_ITEM_TEXT_CLASS]}`,
    );
    return (singleLineText && singleLineText.textContent) || '';
  }

  /**
   * Initialize selectedIndex value based on pre-selected list items.
   */
  initializeListType() {
    this.isInteractive = matches(
      this.root,
      strings.ARIA_INTERACTIVE_ROLES_SELECTOR,
    );

    if (this.isEvolutionEnabled && this.isInteractive) {
      const selection = Array.from(
        this.root.querySelectorAll<HTMLElement>(strings.SELECTED_ITEM_SELECTOR),
        (listItem) => this.listElements.indexOf(listItem),
      );

      if (matches(this.root, strings.ARIA_MULTI_SELECTABLE_SELECTOR)) {
        this.selectedIndex = selection;
      } else if (selection.length > 0) {
        this.selectedIndex = selection[0];
      }
      return;
    }

    const checkboxListItems = this.root.querySelectorAll<HTMLElement>(
      strings.ARIA_ROLE_CHECKBOX_SELECTOR,
    );
    const radioSelectedListItem = this.root.querySelector<HTMLElement>(
      strings.ARIA_CHECKED_RADIO_SELECTOR,
    );

    if (checkboxListItems.length) {
      const preselectedItems = this.root.querySelectorAll<HTMLElement>(
        strings.ARIA_CHECKED_CHECKBOX_SELECTOR,
      );
      this.selectedIndex = Array.from(preselectedItems, (listItem) =>
        this.listElements.indexOf(listItem),
      );
    } else if (radioSelectedListItem) {
      this.selectedIndex = this.listElements.indexOf(radioSelectedListItem);
    }
  }

  /**
   * Updates the list item at itemIndex to the desired isEnabled state.
   * @param itemIndex Index of the list item
   * @param isEnabled Sets the list item to enabled or disabled.
   */
  setEnabled(itemIndex: number, isEnabled: boolean) {
    this.foundation.setEnabled(itemIndex, isEnabled);
  }

  /**
   * Given the next desired character from the user, adds it to the typeahead
   * buffer. Then, attempts to find the next option matching the buffer. Wraps
   * around if at the end of options.
   *
   * @param nextChar The next character to add to the prefix buffer.
   * @param startingIndex The index from which to start matching. Defaults to
   *     the currently focused index.
   * @return The index of the matched item.
   */
  typeaheadMatchItem(nextChar: string, startingIndex?: number): number {
    return this.foundation.typeaheadMatchItem(
      nextChar,
      startingIndex,
      /** skipFocus */ true,
    );
  }

  override getDefaultFoundation() {
    // DO NOT INLINE this variable. For backward compatibility, foundations take
    // a Partial<MDCFooAdapter>. To ensure we don't accidentally omit any
    // methods, we need a separate, strongly typed adapter variable.
    const adapter: MDCListAdapter = {
      addClassForElementIndex: (index, className) => {
        const element = this.listElements[index];
        if (element) {
          element.classList.add(this.classNameMap[className]);
        }
      },
      focusItemAtIndex: (index) => {
        this.listElements[index]?.focus();
      },
      getAttributeForElementIndex: (index, attr) =>
        this.listElements[index].getAttribute(attr),
      getFocusedElementIndex: () =>
        this.listElements.indexOf(document.activeElement as HTMLElement),
      getListItemCount: () => this.listElements.length,
      getPrimaryTextAtIndex: (index) =>
        this.getPrimaryText(this.listElements[index]),
      hasCheckboxAtIndex: (index) => {
        const listItem = this.listElements[index];
        return !!listItem.querySelector<HTMLElement>(strings.CHECKBOX_SELECTOR);
      },
      hasRadioAtIndex: (index) => {
        const listItem = this.listElements[index];
        return !!listItem.querySelector<HTMLElement>(strings.RADIO_SELECTOR);
      },
      isCheckboxCheckedAtIndex: (index) => {
        const listItem = this.listElements[index];
        const toggleEl = listItem.querySelector<HTMLInputElement>(
          strings.CHECKBOX_SELECTOR,
        );
        return toggleEl!.checked;
      },
      isFocusInsideList: () => {
        return (
          this.root !== document.activeElement &&
          this.root.contains(document.activeElement)
        );
      },
      isRootFocused: () => document.activeElement === this.root,
      listItemAtIndexHasClass: (index, className) =>
        this.listElements[index].classList.contains(
          this.classNameMap[className],
        ),
      notifyAction: (index) => {
        this.emit<MDCListActionEventDetail>(
          strings.ACTION_EVENT,
          { index },
          /** shouldBubble */ true,
        );
      },
      notifySelectionChange: (changedIndices: number[]) => {
        this.emit<MDCListSelectionChangeDetail>(
          strings.SELECTION_CHANGE_EVENT,
          { changedIndices },
          /** shouldBubble */ true,
        );
      },
      removeClassForElementIndex: (index, className) => {
        const element = this.listElements[index];
        if (element) {
          element.classList.remove(this.classNameMap[className]);
        }
      },
      setAttributeForElementIndex: (index, attr, value) => {
        const element = this.listElements[index];
        if (element) {
          this.safeSetAttribute(element, attr, value);
        }
      },
      setCheckedCheckboxOrRadioAtIndex: (index, isChecked) => {
        const listItem = this.listElements[index];
        const toggleEl = listItem.querySelector<HTMLInputElement>(
          strings.CHECKBOX_RADIO_SELECTOR,
        );
        toggleEl!.checked = isChecked;

        const event = document.createEvent('Event');
        event.initEvent('change', true, true);
        toggleEl!.dispatchEvent(event);
      },
      setTabIndexForListItemChildren: (listItemIndex, tabIndexValue) => {
        const element = this.listElements[listItemIndex];
        const selector = strings.CHILD_ELEMENTS_TO_TOGGLE_TABINDEX;
        Array.prototype.forEach.call(
          element.querySelectorAll<HTMLElement>(selector),
          (el: HTMLElement) => {
            el.tabIndex = Number(tabIndexValue);
          },
        );
      },
    };
    return new MDCListFoundation(adapter);
  }

  /**
   * Ensures that at least one item is focusable if the list is interactive and
   * doesn't specify a suitable tabindex.
   */
  private ensureFocusable() {
    if (this.isEvolutionEnabled && this.isInteractive) {
      if (
        !this.root.querySelector(
          `.${this.classNameMap[cssClasses.LIST_ITEM_CLASS]}[tabindex="0"]`,
        )
      ) {
        const index = this.initialFocusIndex();
        if (index !== -1) {
          this.listElements[index].tabIndex = 0;
        }
      }
    }
  }

  private initialFocusIndex(): number {
    if (this.selectedIndex instanceof Array && this.selectedIndex.length > 0) {
      return this.selectedIndex[0];
    }
    if (
      typeof this.selectedIndex === 'number' &&
      this.selectedIndex !== numbers.UNSET_INDEX
    ) {
      return this.selectedIndex;
    }
    const el = this.root.querySelector<HTMLElement>(
      `.${this.classNameMap[cssClasses.LIST_ITEM_CLASS]}:not(.${
        this.classNameMap[cssClasses.LIST_ITEM_DISABLED_CLASS]
      })`,
    );
    if (el === null) {
      return -1;
    }
    return this.getListItemIndex(el);
  }

  /**
   * Used to figure out which list item this event is targeting. Or returns -1
   * if there is no list item
   */
  private getListItemIndex(el: Element) {
    const nearestParent = closest(
      el,
      `.${this.classNameMap[cssClasses.LIST_ITEM_CLASS]}, .${
        this.classNameMap[cssClasses.ROOT]
      }`,
    );

    // Get the index of the element if it is a list item.
    if (
      nearestParent &&
      matches(
        nearestParent,
        `.${this.classNameMap[cssClasses.LIST_ITEM_CLASS]}`,
      )
    ) {
      return this.listElements.indexOf(nearestParent as HTMLElement);
    }

    return -1;
  }

  /**
   * Used to figure out which element was clicked before sending the event to
   * the foundation.
   */
  private handleFocusInEvent(event: FocusEvent) {
    const index = this.getListItemIndex(event.target as Element);
    this.foundation.handleFocusIn(index);
  }

  /**
   * Used to figure out which element was clicked before sending the event to
   * the foundation.
   */
  private handleFocusOutEvent(event: FocusEvent) {
    const index = this.getListItemIndex(event.target as Element);
    this.foundation.handleFocusOut(index);
  }

  /**
   * Used to figure out which element was focused when keydown event occurred
   * before sending the event to the foundation.
   */
  private handleKeydownEvent(event: KeyboardEvent) {
    const index = this.getListItemIndex(event.target as Element);
    const target = event.target as Element;
    this.foundation.handleKeydown(
      event,
      target.classList.contains(this.classNameMap[cssClasses.LIST_ITEM_CLASS]),
      index,
    );
  }

  /**
   * Used to figure out which element was clicked before sending the event to
   * the foundation.
   */
  private handleClickEvent(event: MouseEvent) {
    const index = this.getListItemIndex(event.target as Element);
    const target = event.target as Element;
    this.foundation.handleClick(
      index,
      matches(target, strings.CHECKBOX_RADIO_SELECTOR),
      event,
    );
  }
}
