import { CommonModule, DOCUMENT } from '@angular/common';
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  forwardRef,
  Inject,
  Input,
  OnDestroy,
  Output,
  QueryList,
  Renderer2,
  TemplateRef,
  ViewChild,
  ViewChildren,
  ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject } from 'rxjs';
import { selectAnimations } from '../../shared/animations';
import { Utils } from '../../utils/utils.util';
import { FormFieldDirective } from '../form-field/form-field.directive';
import { FormItemComponent } from '../form-item/form-item.component';
import { HighlightDirective } from '../highlight/highlight.directive';
import { ListGroupComponent } from '../list-group/list-group.component';
import { ListItemComponent } from '../list-item/list-item.component';
import { AutocompleteOption } from './autocomplete.model';

@Component({
  selector: 'nj-autocomplete',
  templateUrl: './autocomplete.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => AutocompleteComponent),
      multi: true
    }
  ],
  animations: [selectAnimations.transformList],
  encapsulation: ViewEncapsulation.None,
  standalone: true,
  imports: [
    ListGroupComponent,
    ListItemComponent,
    FormItemComponent,
    FormFieldDirective,
    HighlightDirective,
    CommonModule
  ]
})
export class AutocompleteComponent
  extends FormItemComponent
  implements ControlValueAccessor, AfterContentInit, OnDestroy
{
  private readonly INPUT_BORDER_IN_PX = 3;
  private readonly LIST_OFFSET_IN_PX = 4;
  /**
   * @ignore
   */
  private unsubscribe = new Subject<void>();

  /**
   * @ignore
   */
  private _parentElement: HTMLElement;

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

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

  /**
   * @ignore
   */
  private clickListenerDisposeFct: Function;

  /**
   * @ignore
   */
  private scrollListenerDisposeFct: Function;

  /**
   * Bandaid hack to prevent a weird focusout event bug happening when
   * `appendTo` is set and an item is selected. For *some* reason, the focusout
   * event is triggered multiple times when opening the list, which causes
   * unexpected behavior.
   *
   * This variable allows to temporarily "disables" the `handleFocusout` handler.
   *
   * FIXME: Find an actual solution to this problem instead of a bandaid hack.
   * @ignore
   */
  private ignoreFocusout = false;

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

  /**
   * @ignore
   */
  isFiltered = false;

  /**
   * @ignore
   */
  selectedValue: AutocompleteOption;

  /**
   * @ignore
   */
  filteredData: AutocompleteOption[];

  /**
   * Input search text
   * @ignore
   */
  searchText: string;

  /**
   * Index of currently selected suggestion. -1 if no suggestion is currently selected
   * @ignore
   */
  focusIndex: number = -1;

  protected activeIndex = -1;

  /**
   * Id of currently selected item. Null if no suggestion is currently selected
   * @ignore
   */
  protected focusedItemId: string | null = null;

  /**
   * Live zone content. It will be announced by assistive technologies everytime it is changed.
   * @ignore
   */
  liveZoneContent = '';

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

  /**
   * Whether to show number of results or no
   */
  @Input()
  showNumberOfResults = true;

  /**
   * No results message to display
   */
  @Input()
  noResultMessage = 'No Results';

  /**
   * Result message, formatted like `{numberOfResults} {resultsNumberMessage}`
   */
  @Input()
  resultsNumberMessage = 'results';

  /**
   * Whether to show number of results or no
   */
  @Input()
  showNoResultsMessage = true;

  /**
   * Limit of results to show on search
   */
  @Input()
  searchLimit?: number;

  /**
   * Selector that points to dom node where the list should be rendered
   */
  @Input()
  appendTo: string;

  /**
   * Track by Function
   */
  @Input()
  trackByFn: (index: number, option: AutocompleteOption) => any;

  /**
   * @ignore
   */
  _data: AutocompleteOption[];

  /**
   * Autocomplete data
   */
  @Input() set data(value: AutocompleteOption[]) {
    this._data = value;
    this.updateList();
  }

  get data(): AutocompleteOption[] {
    return this._data;
  }

  /**
   * Suggestion list text alternative for assistive technologies.
   */
  @Input() listLabel: string;

  /**
   * Instructions on how to navigate the list. It is append after the input label.
   * @example "Use the UP / DOWN arrows to navigate within the suggestion list. Press Enter to select an option. On touch devices, use swipe to navigate and double tap to select an option"
   */
  @Input() inputInstructions: string;

  /**
   * Emits value of searched value on input type
   */
  @Output()
  search: EventEmitter<string> = new EventEmitter<string>();

  /**
   * @ignore
   */
  @ViewChild('input') inputRef: ElementRef;

  /**
   * @ignore
   */
  @ViewChild('optionsList', { read: ElementRef }) optionsList: ElementRef;

  /**
   * Option items
   * @ignore
   */
  @ViewChildren(ListItemComponent)
  selectOptions: QueryList<ListItemComponent>;

  /**
   * @ignore
   */
  @ContentChild('njAutocompleteOptionLabel', { read: TemplateRef })
  optionLabelTemplate: TemplateRef<any>;

  /**
   * @ignore
   */
  @ContentChild('njAutocompleteSearchResults', { read: TemplateRef })
  searchResultsTemplate: TemplateRef<any>;

  /**
   * @ignore
   */
  @ContentChild('njAutocompleteNoResult', { read: TemplateRef })
  noResultTemplate: TemplateRef<any>;

  constructor(
    private renderer: Renderer2,
    private elementRef: ElementRef<HTMLElement>,
    private cdr: ChangeDetectorRef,
    @Inject(DOCUMENT) private _document
  ) {
    super();
    this.initScrollListener();
  }

  /**
   * @ignore
   */
  ngAfterContentInit() {
    super.ngAfterContentInit();
  }

  /**
   * @ignore
   */
  ngOnDestroy() {
    this.unsubscribe.next();
    this.unsubscribe.complete();
    this.clickListenerDisposeFct?.();
    this.scrollListenerDisposeFct?.();
    if (this.appendTo) {
      this.removeAppendedElementFromParent();
    }
  }

  /**
   * Content of hint item and hidden.
   * @ignore
   */
  private createResultsMessageContent() {
    const elements = this.filteredData;

    if (elements.length === 0) {
      return this.noResultMessage;
    }

    return `${elements.length} ${this.resultsNumberMessage}`;
  }

  /**
   * Update displayed suggestions and update live zone
   * @private
   */
  private updateList(): void {
    if (!this.isFiltered || !this.searchText || Utils.isUndefinedOrNull(this._data)) {
      this.filteredData = this._data;
    } else {
      this.filteredData = this._data
        .filter((option) => Utils.normalizeAndSearchInText(option?.label, this.searchText))
        .slice(0, this.searchLimit);
    }

    this.liveZoneContent = this.createResultsMessageContent();
    this.processActiveOption();
  }

  private processActiveOption() {
    this.activeIndex = this.filteredData.findIndex((item) => item.label === this.searchText);
  }

  get interactedItemIndex() {
    if (this.focusIndex !== -1) {
      return this.focusIndex;
    }
    return this.activeIndex !== -1 ? this.activeIndex : 0;
  }

  private scrollOnListOpening() {
    const element = this.selectOptions?.get(this.interactedItemIndex)?.el?.nativeElement;
    element.scrollIntoView({ block: 'nearest' });
  }

  private appendAndComputeListPosition() {
    if (!this.appendTo || !this.optionsList?.nativeElement) {
      return;
    }
    const focusedEl = document.activeElement as HTMLElement;

    this._parentElement = this._document.querySelector(this.appendTo);
    this.computeListPosition();
    this._parentElement.appendChild(this.optionsList.nativeElement);

    // The appendChild() call above might remove the focus from the currently
    // selected element so we restore the focus to where it was before the append.
    focusedEl?.focus();
  }

  /**
   * @private
   */
  private computeListPosition() {
    if (this.optionsList?.nativeElement && this.inputRef?.nativeElement) {
      const inputBoundingRect = this.inputRef?.nativeElement?.getBoundingClientRect();
      if (inputBoundingRect) {
        this.optionsList.nativeElement.style = `
          position: fixed;
          left: ${inputBoundingRect.left - this.LIST_OFFSET_IN_PX}px;
          top: ${inputBoundingRect.top + inputBoundingRect.height + this.LIST_OFFSET_IN_PX + this.INPUT_BORDER_IN_PX}px;
          min-width: ${inputBoundingRect.width + this.LIST_OFFSET_IN_PX * 2}px;
          transform: scaleY(1);
          opacity: 1;
        `;
      }
    }
  }

  /**
   * @ignore
   */
  private removeAppendedElementFromParent() {
    if (this.optionsList?.nativeElement) {
      this.renderer.removeChild(this._parentElement, this.optionsList.nativeElement);
    }
  }

  /**
   * @ignore
   */
  private initScrollListener() {
    this.scrollListenerDisposeFct = this.renderer.listen('window', 'scroll', (_: Event) => {
      if (this.appendTo && this.isOpen) {
        this.computeListPosition();
      }
    });
  }

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

  /**
   * 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 {
    if (!this.inputRef) {
      return;
    }
    this.isDisabled = isDisabled;
  }

  /**
   * Implemented as part of ControlValueAccessor.
   * @ignore
   */
  writeValue(value: AutocompleteOption): void {
    this.selectedValue = value;
    this.searchText = value?.label ?? '';
    if (this.inputRef) {
      this.inputRef.nativeElement.value = value?.label ?? '';
    }
    this.cdr.markForCheck();
  }

  /** Open the suggestion list. */
  openList() {
    this.updateList();
    this.isOpen = true;
    this.ignoreFocusout = true;

    setTimeout(() => {
      this.scrollOnListOpening();

      if (this.appendTo) {
        this.appendAndComputeListPosition();
      }

      this.ignoreFocusout = false;
    });
  }

  /** Close the suggestion list. */
  closeList() {
    this.isOpen = false;
    this.focusIndex = -1;
    this.activeIndex = -1;
    if (this.appendTo) {
      this.removeAppendedElementFromParent();
    }
    this.cdr.markForCheck();
  }

  /**
   * Toggle the suggestion list.
   * @ignore
   */
  handleInputClick() {
    if (this.isOpen) {
      this.closeList();
    } else {
      this.isFiltered = false;
      this.openList();
    }
  }

  /**
   * Handle input change and save searchText
   * @ignore
   */
  handleInputEvent(event: InputEvent): void {
    if (event?.data === '') {
      return;
    }

    this.searchText = (event?.target as HTMLInputElement)?.value;
    this.search.emit(this.searchText);

    const matchingOption = this._data.find((option) => option.label === this.searchText);

    if (matchingOption) {
      this.selectItem(matchingOption);
    } else {
      this.updateList();
      setTimeout(() => {
        if (this.filteredData.length) {
          this.focusIndex = 0;

          this.focusFocusedOption();
        }
      });
    }
  }

  /**
   * @ignore
   */
  getItemId(index: number) {
    return `${this.inputId}-item-${index}`;
  }

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

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

  /**
   * @ignore
   */
  private focusFocusedOption() {
    const element = this.selectOptions?.get(this.focusIndex)?.el?.nativeElement;
    this.focusedItemId = element?.id;
    element.scrollIntoView({ block: 'nearest' });
  }

  /**
   * @ignore
   */
  private selectNextOption() {
    if (this.filteredData.length) {
      if (this.focusIndex !== -1) {
        const nextIndex = (this.focusIndex + 1) % this.filteredData.length;
        this.focusIndex = nextIndex;
      } else {
        this.focusIndex = this.interactedItemIndex;
      }

      this.focusFocusedOption();
    }
  }

  /**
   * @ignore
   */
  private selectPreviousOption() {
    if (this.filteredData.length) {
      const previousIndex = this.focusIndex === 0 ? this.filteredData.length - 1 : this.focusIndex - 1;
      this.focusIndex = previousIndex;
      this.focusFocusedOption();
    }
  }

  /**
   * @ignore
   */
  private unselectOption() {
    this.focusIndex = -1;
    this.focusedItemId = null;
  }

  /**
   * @ignore
   */
  handleKeydownEvent(e: KeyboardEvent) {
    if (e.key === 'Tab') {
      // Ignore Tab key to not mess up with focusout event handler
      return;
    }

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!this.isOpen) {
          this.isFiltered = false;
          this.openList();
          this.focusIndex = this.interactedItemIndex;
          this.focusFocusedOption();
        } else {
          this.selectNextOption();
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        if (!this.isOpen) {
          this.isFiltered = false;
          this.openList();
        }
        this.focusIndex = this.interactedItemIndex;
        this.selectPreviousOption();
        break;
      case 'Escape':
        e.preventDefault();
        if (this.isOpen) {
          this.closeList();
        }
        break;
      case 'Enter':
        if (this.isOpen && this.focusIndex !== -1) {
          this.selectItem(this.filteredData[this.focusIndex]);
        }
        break;
      default:
        // Ignore non-character keys and shortcut combinations
        const keyIsPrintable = (e.key.length === 1 || e.key === 'Backspace') && !e.metaKey && !e.altKey && !e.ctrlKey;
        if (keyIsPrintable) {
          this.isFiltered = true;
          this.unselectOption();

          if (!this.isOpen) {
            this.openList();
          }

          setTimeout(() => {
            const matchingOption = this.getMatchinOption();
            this._onChange(matchingOption);
            this.cdr.markForCheck();
          });
        }
    }
  }

  /**
   * @ignore
   */
  private getMatchinOption() {
    const filteredData = this._data?.filter((option) => this.searchText === option.label);
    return filteredData?.[0];
  }

  /**
   * @ignore
   */
  selectItem(option: AutocompleteOption) {
    this.closeList();
    if (this.inputRef) {
      this.inputRef.nativeElement.value = option.label;
    }
    this.searchText = option.label;
    this.selectedValue = option;
    this.unselectOption();

    this._onChange(option);
    this.cdr.markForCheck();
  }

  /**
   * Closes the suggestion list if the focus is moved outside of the autocomplete.
   * @ignore
   */
  handleFocusout(e: FocusEvent) {
    if (this.ignoreFocusout) {
      return;
    }

    if (
      !this.elementRef?.nativeElement.contains(e.relatedTarget as Node) &&
      !this.optionsList?.nativeElement.contains(e.relatedTarget as Node)
    ) {
      this.closeList();
    }
  }

  /**
   * @ignore
   * @param index
   * @param option
   */
  trackByOption(index: number, option: AutocompleteOption) {
    if (this.trackByFn) {
      return this.trackByFn(index, option);
    }
    return option;
  }
}
