import {CommonModule, DOCUMENT} from '@angular/common';
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  ElementRef,
  forwardRef,
  Inject,
  Input,
  OnDestroy,
  OnInit,
  QueryList,
  ViewChild,
  ViewChildren,
  ViewEncapsulation,
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {fromEvent, race, Subject, takeUntil} from 'rxjs';
import {selectAnimations} from '../../shared/animations';
import {FormFieldDirective} from '../form-field/form-field.directive';
import {FormItemComponent} from '../form-item/form-item.component';
import {ListGroupComponent} from '../list-group/list-group.component';
import {ListItemComponent} from '../list-item/list-item.component';
import {TagComponent} from '../tag/tag.component';
import {TagSize} from '../tag/tag.model';

@Component({
  selector: 'nj-multi-select',
  templateUrl: './multi-select.component.html',
  styleUrls: ['./multi-select.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => MultiSelectComponent),
      multi: true,
    },
  ],
  animations: [selectAnimations.transformList],
  encapsulation: ViewEncapsulation.None,
  standalone: true,
  imports: [TagComponent, FormItemComponent, FormFieldDirective, ListGroupComponent, CommonModule]
})
export class MultiSelectComponent
  extends FormItemComponent
  implements OnInit, AfterViewInit, ControlValueAccessor, OnDestroy {
  private static readonly ESCAPE_CODE = 'Escape';
  private static readonly ENTER_CODE = 'Enter';
  private static readonly UP_CODE = 'ArrowUp';
  private static readonly DOWN_CODE = 'ArrowDown';
  /*
    Regex matching every alpha-numeric characters.

    \d : every digits
    \p{Letter} : every letters in the latin alphabet including letters with diacritics

    The "u" flag enables unicode mode required to use `\p{Letter}`.

    See :
    - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes#general_categories
    - https://unicode.org/reports/tr18/#General_Category_Property
  */
  private static readonly ALPHA_NUMERIC_REGEX = /^[\d\p{Letter}]$/u;

  /**
   * @ignore
   */
  private _onChange = (_: any): void => {
  };

  /**
   * @ignore
   */
  private _onTouched = (): void => {
  };

  /**
   * Notifier used to stop items click event subscription.
   * @ignore
   */
  private unsubscribe = new Subject<void>();

  private childOptionsChange = new Subject<void>();

  /**
   * @ignore
   */
  isOpen = false;

  /**
   * @ignore
   */
  selectedIndexes: Set<number> = new Set<number>();

  /**
   * @ignore
   */
  selectedValues: Set<string> = new Set<string>();

  /**
   * Icon name
   */
  @Input() iconName = 'keyboard_arrow_down';

  /**
   * Label used for accessibility related attributes on button and list.
   * Should be the same value (text only) as the `<label>` element
   */
  @Input() fieldLabel: string;

  /**
   * Instructions on how to navigate the list. It is append after the input label.
   * @example "Use up and down arrows and Enter to select a value"
   */
  @Input() listNavigationLabel: string;

  /**
   * Button default label when no value is selected. It is appended after the input label.
   * @example "Select a value"
   */
  @Input() buttonDefaultValueLabel: string;

  /**
   * Max tags to display
   */
  @Input() maxTagsToDisplay: number;

  /**
   * Whether or no to display selected items. When set to `false` and number of selected > 1,
   * instead of displaying each selected item as tags it only display one tag `X selected`
   */
  @Input() displaySelectedItems = true;

  /**
   * Selected text when `displaySelectedItems = false`:
   * @example '5 selected`
   */
  @Input() selectedText: string = 'selected';

  /**
   * Selected options tag color. Can only be grey or brand
   */
  @Input() tagColor: 'brand' | 'grey' = 'grey';

  /**
   * Tag close label for accessibility, default is `Deselect` and it will be followed by the tag label. So focusing the close icon will read `Remove tag label`.
   * Make sure to set a meaningful value and a translated one
   */
  @Input() tagCloseLabel = 'Deselect';

  /**
   * Tag close label for accessibility, default is `Deselect all`. So focusing the close icon will read `Deselect all`.
   * Make sure to set a meaningful value and a translated one
   */
  @Input() tagResetSelectionLabel?: string = 'Deselect all';

  /**
   * Trigger button to toggle the list
   * @ignore
   */
  @ViewChild('button') buttonEl: ElementRef<HTMLButtonElement>;

  /**
   * Trigger button to toggle the list
   * @ignore
   */
  @ViewChild('input') input: ElementRef<HTMLInputElement>;

  /**
   * List containing options
   * @ignore
   */
  @ViewChild(ListGroupComponent) listEl: ListGroupComponent;

  /**
   * List containing tags
   * @ignore
   */
  @ViewChildren('tags') tags: QueryList<TagComponent>;

  /**
   * Option items
   * @ignore
   */
  @ContentChildren(ListItemComponent, {descendants: true}) selectOptions: QueryList<ListItemComponent>;

  constructor(private readonly element: ElementRef<HTMLElement>, private readonly cdr: ChangeDetectorRef, @Inject(DOCUMENT) private document) {
    super();
  }

  ngOnInit() {
    fromEvent(this.document, 'click')
      .pipe(takeUntil(this.unsubscribe))
      .subscribe((e: MouseEvent) => this.handleOutsideClick(e));
  }

  ngAfterViewInit() {
    this.setInputsAndListenersOnOptions();
    this.updateSelectedIndexes();
    this.cdr.detectChanges();

    this.selectOptions?.changes
      .pipe(takeUntil(this.unsubscribe))
      .subscribe(() => {
        setTimeout(() => {
          this.setInputsAndListenersOnOptions();
          this.updateSelectedIndexes();
          this.cdr.detectChanges();
        });
      });
  }

  ngOnDestroy() {
    this.unsubscribe.next();
    this.unsubscribe.complete();
  }

  setInputsAndListenersOnOptions() {
    this.childOptionsChange.next();
    const unsubscribeCond$ = race(this.unsubscribe, this.childOptionsChange);

    this.selectOptions?.forEach((item, index) => {
      item.role = 'option';
      item.updateSelected(this.selectedIndexes.has(index));
      item.isCheckboxContent = true;
      item.checkboxContentId = this.getOptionId(index);

      item.itemClick
        .pipe(takeUntil(unsubscribeCond$))
        .subscribe(() => {
          this.toggleItem(item, index);
        });
    });
  }

  updateSelectedIndexes() {
    const selectOptionsArr = this.selectOptions?.toArray();
    if (selectOptionsArr) {
      this.selectedValues = new Set(
        [...this.selectedValues].filter(value => selectOptionsArr.some(opt => {
          return opt?.getValue() === value;
        }))
      );

      this.selectedIndexes = new Set(
        selectOptionsArr
          .map((opt, index) => {
            return this.selectedValues.has(opt.getValue()) ? index : -1;
          })
          .filter(index => index >= 0)
      );
    } else {
      this.selectedIndexes.clear();
      this.selectedValues.clear();
    }

    this.updateOptionsActive();
  }

  /**
   * @ignore
   */
  toggleByIndex(e: MouseEvent, index: number, tagIndex: number) {
    e?.stopPropagation();
    const item = this.selectOptions.get(index);
    this.toggleItem(item, index);
    // When clicking with a mouse e.detail counts the number of clicks, however when using keyboard it is always 0
    const isEventTriggeredWithKeyboard = e?.detail === 0;
    if (!isEventTriggeredWithKeyboard) {
      this.buttonEl?.nativeElement?.focus();
      return;
    }
    // We use a set timeout to make sure the focus is done after is re-rendered
    setTimeout(() => {
      if (this.tags.length) {
        const indexToFocus = tagIndex === this.tags.length ? tagIndex - 1 : tagIndex;
        this.tags.get(indexToFocus)?.focusIconButton();
      } else {
        this.buttonEl?.nativeElement?.focus();
      }
    });
  }

  /**
   * @ignore
   */
  resetSelection(e: Event) {
    e.stopPropagation();
    this.buttonEl.nativeElement.focus();
    this.selectedIndexes.clear();
    this.selectedValues.clear();
    this.updateOptionsActive();
    this._onChange(Array.from(this.selectedValues));
  }

  private toggleItem(item: ListItemComponent, index: number) {
    if (!item) {
      return;
    }
    this.toggleValueInSelectedValue(item?.getValue());
    this.toggleIndexInSelected(index);
    this.updateOptionsActive();
    this.cdr.markForCheck();
    this._onChange(Array.from(this.selectedValues));
  }

  private toggleValueInSelectedValue(value: string) {
    if (this.selectedValues.has(value)) {
      this.selectedValues.delete(value);
    } else {
      this.selectedValues.add(value);
    }
  }

  private toggleIndexInSelected(index: number) {
    if (this.selectedIndexes.has(index)) {
      this.selectedIndexes.delete(index);
    } else {
      this.selectedIndexes.add(index);
    }
  }

  private openList() {
    this.isOpen = true;

    this.focusedIndex = this.selectedIndexes.size ? Array.from(this.selectedIndexes)[this.selectedIndexes.size - 1] : 0;

    this.selectOptions?.toArray().forEach((el, i) => {
      if (!el) {
        return;
      }
      el.isActive = this.selectedIndexes.has(i);
    });

    setTimeout(() => {
      if (!this.selectedIndexes?.size) {
        // Focus the `ul` element
        this.listEl?.rootEl.nativeElement.focus();
        // The scrolling element is not the `ul` node but the `nj-list-group`
        this.listEl?.element.nativeElement.scrollTo({top: 0});
      }
    });
  }

  private closeList() {
    this.isOpen = false;
    this.cdr.markForCheck();
  }

  /**
   * @ignore
   */
  toggleIsOpen() {
    if (this.isOpen) {
      this.closeList();
    } else {
      this.openList();
    }
  }

  /**
   * @ignore
   */
  handleListKeydown(e: KeyboardEvent) {
    // Escape key closes the list and focuses the button
    if (e.code === MultiSelectComponent.ESCAPE_CODE) {
      this.closeList();
      setTimeout(() => {
        this.buttonEl?.nativeElement.focus();
      });
    }

    // Navigate between options and set `focusedIndex`
    if (e.code === MultiSelectComponent.UP_CODE) {
      e.preventDefault();
      // Don't loop back to the end of the list
      if (this.focusedIndex > 0) {
        this.focusedIndex -= 1;
      }
    }

    if (e.code === MultiSelectComponent.DOWN_CODE) {
      e.preventDefault();
      // Don't loop back to the begining of the list
      if (this.focusedIndex < this.selectOptions?.length - 1) {
        this.focusedIndex += 1;
      }
    }

    // Select the current `focusedIndex` option
    if (e.code === MultiSelectComponent.ENTER_CODE) {
      e.preventDefault();
      if (this.focusedIndex !== -1) {
        this.toggleItem(this.selectOptions?.get(this.focusedIndex), this.focusedIndex);
        this._onChange(Array.from(this.selectedValues));
      }
    }

    // Jump to first option matching first letter
    if (MultiSelectComponent.ALPHA_NUMERIC_REGEX.test(e.key)) {
      const goToIndex = this.selectOptions
        ?.toArray()
        .findIndex(
          (item) => item.getValue()[0].toLowerCase() === e.key.toLowerCase()
        );

      if (goToIndex !== -1) {
        this.focusedIndex = goToIndex;
      }
    }
  }

  /**
   * @ignore
   */
  handleFocusOut(e: FocusEvent) {
    const relatedTarget = e?.relatedTarget as Node;
    if (!relatedTarget) {
      return;
    }
    if (!this.element?.nativeElement?.contains(relatedTarget)) {
      this.closeList();

      if (this._onTouched) {
        this._onTouched();
      }
    }
  }

  /**
   * @ignore
   */
  handleOutsideClick(e: MouseEvent) {
    if (!this.element?.nativeElement?.contains(e.target as Node)) {
      this.closeList();

      if (this._onTouched) {
        this._onTouched();
      }
    }
  }

  /**
   * Implemented as part of ControlValueAccessor.
   * @ignore
   */
  registerOnChange(fn: any): void {
    this._onChange = fn;
  }

  /**
   * Implemented as part of ControlValueAccessor.
   * @ignore
   */
  registerOnTouched(fn: any): void {
    this._onTouched = fn;
  }

  /**
   * Implemented as part of ControlValueAccessor.
   * @ignore
   */
  setDisabledState(isDisabled: boolean): void {
    this.isDisabled = isDisabled;
  }

  /**
   * Implemented as part of ControlValueAccessor.
   * @ignore
   */
  writeValue(values?: string[]): void {
    this.selectedValues.clear();
    this.selectedIndexes.clear();
    if (values?.length) {
      for (const value of values) {
        this.selectedValues.add(value);
        const indexToAdd = this.selectOptions?.toArray()?.findIndex(item => item.getValue() === value);
        if (indexToAdd >= 0) {
          this.selectedIndexes.add(indexToAdd);
        }
      }
    }
    this.updateOptionsActive();
    this.cdr.markForCheck();
  }

  private updateOptionsActive() {
    this.selectOptions?.forEach((item) => {
      item.updateSelected(this.selectedValues.has(item.getValue()));
    });
  }

  /**
   * Label (≠ value) of selected option
   * @ignore
   */
  getLabelAtIndex(index: number): string {
    return this.selectOptions?.get(index)?.getLabel() ?? '';
  }

  /**
   * Close aria Label of taf to close
   * @param index
   */
  getTagCloseLabel(index: number): string {
    return `${this.tagCloseLabel} ${this.getLabelAtIndex(index)}`;
  }

  /**
   * Aria-label for the trigger button element.
   * @ignore
   */
  get buttonLabel(): string {
    return `${this.fieldLabel} - ${this.buttonDefaultValueLabel}`;
  }

  /**
   * @ignore
   */
  get formattedInputValue(): string {
    if (!this.selectedValues) {
      return '';
    }
    return Array.from(this.selectedValues).join(',');
  }

  /**
   * @ignore
   */
  getAdditionalClass(): string {
    return `nj-form-item--select nj-form-item--custom-list nj-form-item--multi-select${
      this.isOpen ? ' nj-form-item--open' : ''
    }`;
  }

  /**
   * @ignore
   */
  getSubscriptId(): string {
    return `${this.inputId}-subscript`;
  }

  /**
   * @ignore
   */
  getInstructionsId(): string {
    return `${this.inputId}-instructions`;
  }

  /**
   * @ignore
   */
  getDescriptionId(): string {
    return `${this.getSubscriptId()} ${this.getInstructionsId()}`;
  }

  /**
   * Index of the currently focused option.
   */
  private get focusedIndex(): number {
    return this.selectOptions
      ?.toArray()
      .findIndex(
        (item) => this.document.activeElement === item.el.nativeElement
      );
  }

  private set focusedIndex(value: number) {
    this.selectOptions?.forEach((el, i) => {
      el.ariaSelected = i === value;
    });

    setTimeout(() => {
      if (value >= 0) {
        this.selectOptions?.get(value)?.el?.nativeElement?.focus();
      }
    });
  }

  /**
   * @ignore
   */
  get selectedIndexesToShow(): number[] {
    if (!this.selectedIndexes) {
      return;
    }
    if (!this.maxTagsToDisplay) {
      return [...this.selectedIndexes];
    }
    return [...this.selectedIndexes].splice(0, this.maxTagsToDisplay);
  }

  /**
   * @ignore
   */
  get tagSize(): TagSize {
    switch (this.size) {
      case 'xlarge':
        return 'md';
      case 'small':
        return 'xs';
      default:
        return 'sm';
    }
  }

  /**
   * @ignore
   */
  get selectIndexAsArray(): number[] {
    return [...this.selectedIndexes];
  }

  private getOptionId(index: number): string {
    return `${this.inputId}_option-${index}`;
  }
}
