/* eslint-disable no-case-declarations */
/* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable @angular-eslint/no-input-rename */
/* eslint-disable @typescript-eslint/no-empty-function */
/**
 * Copyright (C) 2022 Robert Bosch GmbH Copyright (C) 2022 Robert Bosch Manufacturing Solutions GmbH, Germany. All rights reserved.
 */
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import {
  ChangeDetectorRef,
  Component,
  ContentChildren,
  ElementRef,
  Input,
  OnDestroy,
  OnInit,
  QueryList,
  ViewChild,
  AfterViewChecked,
  EventEmitter,
  Output,
  DoCheck,
  Optional,
  Self
} from '@angular/core';
import { NgControl, ControlValueAccessor, Validators, AbstractControl } from '@angular/forms';
import {
  MatCheckboxDefaultOptions,
  MAT_CHECKBOX_DEFAULT_OPTIONS,
} from '@angular/material/checkbox';
import { ErrorStateMatcher } from '@angular/material/core';
import {
  MatOptgroup,
  MatOption,
  _countGroupLabelsBeforeOption,
} from '@angular/material/core';
import { MAT_ERROR, MatError, MatFormFieldControl, MatHint } from '@angular/material/form-field';
import { Subject } from 'rxjs';
import { elementIsOverflowing, getOffset } from './utils';

/**
 * Interface to provide options while creating a link
 */
export interface Option<T> {
  id: string;
  value: T;
  disabled: boolean;
  viewValue: string;
}

/** Describes the option height used for calculations. */
const OPTION_HEIGHT: Readonly<number> = 48;

/** Describes the height of a label of a option group used for calculations. */
const OPTION_GROUP_LABEL_HEIGHT: Readonly<number> = 42;

/** Change event object that is emitted when the select value has changed. */
export class BciSelectChange {
  constructor(
    /** Reference to the select that emitted the change event. */
    public source: BciSelectComponent,
    /** Current value of the select that emitted the event. */
    public value: any
  ) {}
}

@Component({
  selector: 'bci-select',
  templateUrl: './bci-select.component.html',
  styleUrls: ['./bci-select.component.scss'],
  providers: [
    { provide: MatFormFieldControl, useExisting: BciSelectComponent },
    { provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop' } as MatCheckboxDefaultOptions },
  ],
})
export class BciSelectComponent
  implements OnInit, OnDestroy, AfterViewChecked, DoCheck, MatFormFieldControl<string | string[]>, ControlValueAccessor
{
  constructor(
    public _elementRef: ElementRef,
    public _defaultErrorStateMatcher: ErrorStateMatcher,
    protected _changeDetectorRef: ChangeDetectorRef,
    /** Needed for form field control */
    @Self() @Optional() public ngControl: NgControl
  ) {
    if (this.ngControl) {
      // Note: we provide the value accessor through here, instead of
      // the `providers` to avoid running into a circular import.
      this.ngControl.valueAccessor = this;
    }
  }

  //CUSTOM VARIABLES TO TRACK STATE

  /** Variable to track the index of the active option for keyboard events */
  get activeOption(): MatOption {
    return this._activeOption;
  }
  set activeOption(value: MatOption) {
    this._activeOption = value;
    this.stateChanges.next();
  }
  _activeOption: MatOption;

  /** Whether or not the overlay panel is open. */
  get panelOpen(): boolean {
    return this._panelOpen;
  }
  _panelOpen = false;

  /** Whether or not the overlay panel should open to top or bottom */
  get panelOpensToTop(): boolean {
    return this._panelOpensToTop;
  }
  set panelOpensToTop(value: boolean) {
    this._panelOpensToTop = value;
    this.stateChanges.next();
  }
  _panelOpensToTop = false;

  /** State to track the options that have been selected. */
  get selectedOptions(): MatOption[] {
    return this._selectedOptions;
  }
  set selectedOptions(value: MatOption[]) {
    this._selectedOptions = value;
    this.stateChanges.next();
  }
  _selectedOptions: MatOption[];

  @ContentChildren(MatOption, { descendants: true }) options: QueryList<MatOption>;
  @ContentChildren(MatOptgroup, { descendants: true }) optionGroups: QueryList<MatOptgroup>;
  @ContentChildren(MAT_ERROR, {descendants: true}) _errorChildren: QueryList<MatError>;
  @ContentChildren(MatHint, {descendants: true}) _hintChildren: QueryList<MatHint>;

  /** Trigger that opens the select. */
  @ViewChild('trigger') trigger: ElementRef;

  /** Panel containing the select options. */
  @ViewChild('panel') panel: ElementRef;

  //VARIABLES AND FUNCTIONS FOR ANGULAR/MATFORMFIELD TO WORK

  autofilled?: boolean;
  userAriaDescribedBy?: string;

  /** Counter to give every select a unique ID.  */
  static nextUid = 0;

  /** StateChange Subject to tell MatFormField when component state changes. */
  readonly stateChanges = new Subject<void>();

  /** ID for the select value. */
  _valueId = `bci-select-value-${BciSelectComponent.nextUid++}`;

  /** Whether the select is focused. */
  get focused(): boolean {
    return this._focused || this._panelOpen;
  }
  private _focused = false;

  /** `Callback for when value changes` */
  _onChange: (value: any) => void = () => {};

  /** `Callback for when the select trigger has been touched` */
  _onTouched = () => {};

  /**
   * Keeps track of the previous form control. For comparison if it changes.
   */
  private _previousControl: AbstractControl | null | undefined;

  /** Object used to control when error messages are shown. */
  @Input() errorStateMatcher: ErrorStateMatcher;

  /** Custom width for the select. */
  @Input() width = '192px';

  /** Wheter truncated options in the panel should scroll horizontally. */
  @Input()
  get shouldScrollOptions(): boolean {
    return this._shouldScrollOptions;
  }
  set shouldScrollOptions(value: BooleanInput) {
    this._shouldScrollOptions = coerceBooleanProperty(value);
  }
  private _shouldScrollOptions = false;

  /** Wheter the truncated value in the trigger should scroll horizontally. */
  @Input()
  get shouldScrollValue(): boolean {
    return this._shouldScrollValue;
  }
  set shouldScrollValue(value: BooleanInput) {
    this._shouldScrollValue = coerceBooleanProperty(value);
  }
  private _shouldScrollValue = false;

  /** Event emitted when the selected value has been changed by the user. */
  @Output() readonly selectionChange: EventEmitter<BciSelectChange> = new EventEmitter<BciSelectChange>();

  /** Placeholder to be shown if no value has been selected. */
  @Input()
  get placeholder(): string {
    return this._placeholder;
  }
  set placeholder(value: string) {
    this._placeholder = value;
    this.stateChanges.next();
  }
  private _placeholder = 'Placeholder';

  /** Label of the select. */
  @Input()
  get label(): string {
    return this._label;
  }
  set label(value: string) {
    this._label = value;
    this.stateChanges.next();
  }
  private _label = 'Label';

  /** Whether the select is disabled. */
  @Input()
  get disabled(): boolean {
    return this._disabled ?? false;
  }
  set disabled(value: BooleanInput) {
    this._disabled = coerceBooleanProperty(value);
    this.stateChanges.next();
  }
  private _disabled: boolean | undefined;

  /**
   * Disables the select. Part of the ControlValueAccessor interface required
   * to integrate with Angular's core forms API.
   *
   * @param isDisabled Sets the select's disabled state.
   */
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
    this._changeDetectorRef.markForCheck();
    this.stateChanges.next();
  }

  /** Whether the select is required. */
  @Input()
  get required(): boolean {
    return this._required ?? this.ngControl?.control?.hasValidator(Validators.required) ?? false;
  }
  set required(value: BooleanInput) {
    this._required = coerceBooleanProperty(value);
    this.stateChanges.next();
  }
  private _required: boolean | undefined;

  /** Whether the user should be allowed to select multiple options. */
  @Input()
  get multiple(): boolean {
    return this._multiple;
  }
  set multiple(value: BooleanInput) {
    this._multiple = coerceBooleanProperty(value);
  }
  private _multiple = false;

  /** Value of the select component. */
  @Input()
  get value(): any {
    return this._value;
  }
  set value(newValue: any) {
    const hasSetNewValue = this._setNewValue(newValue);

    if (hasSetNewValue) {
      this.selectedOptions = this.options.filter((opt) => this.value?.includes(opt.value));
      if (this.shouldScrollValue) {
        this._checkTriggerValueOverflow();
      }
      this.selectionChange.emit(this._getChangeEvent(newValue));
      this._onChange(newValue);
    }
  }
  private _value: any | any[] = this.multiple ? [] : '';

  /** Needed for MatFormFieldControl.*/
  @Input()
  get id(): string {
    return this._id;
  }
  set id(value: string) {
    this._id = value;
    this.stateChanges.next();
  }
  private _id: string;

  /** Whether the select is empty. */
  get empty(): boolean {
    return !this.value || (this._multiple && Array.isArray(this.value) && this.value.length <= 0);
  }

  /** Needed for MatFormFieldControl.*/
  get shouldLabelFloat(): boolean {
    return this._panelOpen || !this.empty || (this._focused && !!this._placeholder);
  }

  /** Needed for MatFormFieldControl.*/
  errorState: boolean;

  /** Needed for MatFormFieldControl.*/
  setDescribedByIds(ids: string[]) {
    if (ids.length) {
      this._elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' '));
    } else {
      this._elementRef.nativeElement.removeAttribute('aria-describedby');
    }
  }

  /** Needed for MatFormFieldControl.*/
  onContainerClick() {
    this.trigger.nativeElement.focus();
  }

  /**
   * Sets the select's value. Part of the ControlValueAccessor interface
   * required to integrate with Angular's core forms API.
   *
   * @param value New value to be written to the model.
   */
  writeValue(value: any): void {
    this._setNewValue(value);
  }

  /**
   * Saves a callback function to be invoked when the select's value
   * changes from user input. Part of the ControlValueAccessor interface
   * required to integrate with Angular's core forms API.
   *
   * @param fn Callback to be triggered when the value changes.
   */
  registerOnChange(fn: (value: any) => void): void {
    this._onChange = fn;
  }

  /**
   * Saves a callback function to be invoked when the select is blurred
   * by the user. Part of the ControlValueAccessor interface required
   * to integrate with Angular's core forms API.
   *
   * @param fn Callback to be triggered when the component has been touched.
   */
  registerOnTouched(fn: () => any): void {
    this._onTouched = fn;
  }

  //FUNCTIONS TO USE IN TEMPLATE

  /**
   * Function to check wheter an option is selected.
   * @param option Option to check for.
   * @returns Wheter the option is selected.
   */
  optionIsSelected(option: MatOption): boolean {
    if (option.value === '') return false;
    return this.selectedOptions.includes(option);
  }

  /**
   * Gets value to display in the trigger.
   * @returns String with all selected options separated by comma.
   */
  get displaySelectedOptions() {
    return this.multiple
      ? this.selectedOptions?.map((opt) => opt.viewValue).join(', ')
      : this._findOptionByValue(this.value).viewValue;
  }

  /**
   * Function to determine if an option is the first of its group.
   * @param option Option we check for.
   * @returns Wheter the option is the first of its OptionGroup.
   */
  optionIsFirstOfGroup(option: MatOption): boolean {
    if (!option.group) return false;
    const groupOfOption = this.options.filter((opt) => opt.group === option.group);
    return groupOfOption[0] === option;
  }

  /**
   * Gets group of an option.
   * @param option Option we check for.
   * @returns The group of the option.
   */
  getGroupForOption(option: MatOption): MatOptgroup {
    return this.options.find((opt) => opt.group === option.group).group;
  }

  /**
   * Helps adjust the panel to the top edge of the input when it opens to the top.
   * @returns The correct height the panel needs to adjust for if the panel opens to the top.
   */
  get notificationHeight(): string {
    if (!this.panelOpensToTop) return 'none';
  }

  //CUSTOM FUNCTIONS
  /**
   * @returns Height of the panel
   */
  private get _panelHeight() {
    return this.panel.nativeElement.offsetHeight;
  }

  /** Whether the panel should open. */
  protected _panelCanOpen(): boolean {
    return (!this._panelOpen && !this.disabled) || (Array.isArray(this.value) && this.value?.length > 0);
  }

  /** Finds MatOption from option array by its value. */
  private _findOptionByValue(value: any) {
    return this.options.find((opt) => opt.value === value);
  }

  /** Calculates wheter the select panel should differ from its default behaviour and open to the top. */
  private _calculatePanelOpenDirection() {
    const panelOffset = getOffset(this.trigger?.nativeElement);
    const panelHeight = this._panelHeight;

    // Checks if panel doesn't fit on the bottom AND (if it fits on the top OR if there is more space on the top than on the bottom)
    this.panelOpensToTop =
      panelOffset.bottom - panelHeight < 0 && (panelOffset.top > panelHeight || panelOffset.top > panelOffset.bottom);
  }

  /** Function to determine wheter the label gets truncated and adds CSS classes to display the horizontal scrolling animation. */
  private _checkTriggerValueOverflow() {
    // Detect change that swapped placeholder with text value element
    this._changeDetectorRef.detectChanges();
    const scrollWrapper = (this._elementRef.nativeElement as HTMLElement)
      .querySelector('.bci-select-trigger-value')
      .children.item(0) as HTMLDivElement;
    const textSpan = scrollWrapper.children.item(0) as HTMLSpanElement;
    if (elementIsOverflowing(textSpan, scrollWrapper)) {
      textSpan.classList.add('isOverflowing');
    } else {
      // We have to remove it here since the element doesn't change on value change, only its innerText
      textSpan.classList.remove('isOverflowing');
    }
  }

  /** Function to find all the options that get truncated and adds CSS classes to display the horizontal scrolling animation. */
  private _findOverflowingOptions() {
    const panelChildren = (this.panel?.nativeElement as HTMLUListElement).children;
    Array(panelChildren.length)
      .fill(' ')
      .map((_, i) => {
        const li = panelChildren.item(i) as HTMLLIElement;
        // Dont check overflowing for option group labels.
        if (li.classList.contains('bci-select-panel-optiongroup-label')) return;
        // Spans where the text is in is always the last child of the list item.
        const outerSpan = li.children.item(li.children.length - 1) as HTMLSpanElement;
        const textSpan = outerSpan.children.item(0) as HTMLSpanElement;
        if (elementIsOverflowing(textSpan, outerSpan)) {
          textSpan.classList.add('isOverflowing');
        }
      });
  }

  /**
   * Function to determine how far the select panel should be scrolled to make a option visible.
   * @param option The Option that should be visible.
   * @returns The amount, the select panel should be scrolled to see the currently active option.
   */
  private _scrollOptionIntoView(option: MatOption) {
    const labelCount = _countGroupLabelsBeforeOption(
      this.options.toArray().findIndex((opt) => opt === option),
      this.options,
      this.optionGroups
    );
    const optionOffset =
      OPTION_HEIGHT * this.options.toArray().indexOf(option) + labelCount * OPTION_GROUP_LABEL_HEIGHT;
    const panelHeight = this._panelHeight;

    if (optionOffset < this.panel.nativeElement.scrollTop) {
      return optionOffset;
    }

    if (optionOffset + OPTION_HEIGHT > this.panel.nativeElement.scrollTop + panelHeight) {
      return Math.max(0, optionOffset - panelHeight + OPTION_HEIGHT);
    }

    return this.panel.nativeElement.scrollTop;
  }

  /**
   * Initializes the active option when the panel first opens.
   * If there is a value selected, set the active option to this (or the first possible) value.
   * @param noScroll Wheter the select should not automatically scroll the option into view.
   */
  initActiveOption(noScroll = false): void {
    if (this.multiple) {
      // If multiple selections are allowed, get the first selected one or the first one not disabled.
      if (this.value?.[0]) {
        this._setActiveOption(
          this.selectedOptions.find((opt) => !opt.disabled),
          noScroll
        );
        return;
      }
    }

    this._setActiveOption(
      this.options.find((opt) => opt.value === this.value && !opt.disabled),
      noScroll
    );
  }

  /**
   * Sets the current option that is selected by the keyboard and automatically scrolls it into view.
   * @param option Option to set as the active keyboard selection.
   */
  private _setActiveOption(option: MatOption, noScroll = false): void {
    this.activeOption = option;
    if (!this.panel || !this.activeOption || noScroll) return;
    this.panel.nativeElement.scrollTop = option == this.options[0] ? 0 : this._scrollOptionIntoView(this.activeOption);
  }

  /** Creates a new ChangeEvent to emit, similar to the MatSelect. */
  protected _getChangeEvent(value: any) {
    return new BciSelectChange(this, value);
  }

  /**
   * Sets and assignes a new form control value.
   * @returns a boolean, wheter the new value is different to the old one.
   */
  private _setNewValue(newValue: any | any[]): boolean {
    if (newValue !== this._value || (this._multiple && Array.isArray(newValue))) {
      this._value = newValue;
      return true;
    }
    return false;
  }

  /** Checks where the user clicked and closes the select panel if the user didn't click the trigger or panel. */
  private _closePanelOnClickOutside = (event: MouseEvent) => {
    // Do nothing if clicking ref's element or descendent elements or if panel is not even open
    if (
      !this.panel?.nativeElement ||
      this.panel.nativeElement.contains(event.target) ||
      this.trigger.nativeElement.contains(event.target)
    ) {
      return;
    }

    this.close();
  };

  /**
   * Handler for selecting an option.
   * @param val Value of the option that was clicked.
   */
  public onOptionClick(val: any): void {
    if (this.multiple) {
      if (typeof this.value === 'undefined') {
        throw new Error('Value is undefined.');
      }
      if (this.value?.includes(val)) {
        this.value = this.value.filter((value) => value !== val);
      } else {
        this.value = this.value ? [...this.value, val] : [val];
      }
      return;
    }
    this.value = val;
    this.close();
  }

  /** Toggles the overlay panel open or closed. */
  toggle(): void {
    this.panelOpen ? this.close() : this.open();
  }

  /** Opens the overlay panel. */
  open(): void {
    if (this._panelCanOpen()) {
      this._panelOpen = true;
      this._changeDetectorRef.detectChanges();
      this.stateChanges.next();
      this.initActiveOption();
      if (this.shouldScrollOptions) this._findOverflowingOptions();
      this._calculatePanelOpenDirection();
      this._changeDetectorRef.markForCheck();
    }
  }

  /** Closes the overlay panel. */
  close(): void {
    if (this._panelOpen) {
      this._panelOpen = false;
      this.activeOption = null;
      this.stateChanges.next();
      this.trigger.nativeElement.focus();
      this._changeDetectorRef.markForCheck();
      this._onTouched();
    }
  }

  /** Event handler for keyboard inputs. */
  manageKeyDown = (event: KeyboardEvent) => {
    // Do nothing if select is disabled.
    if (this.disabled) return;
    //Prevent scrolling for arrow inputs if panel is open.
    if (this.panelOpen || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
    }
    if (!this.panelOpen || !this.panel) {
      // Use arrowdown to open the panel if select trigger is focused and the panel is not yet opened.
      if (event.key === 'ArrowDown' && document.activeElement === this.trigger.nativeElement) {
        this.open();
      }
      return;
    }
    // From now on, keyboard events can be handled, which we need when the panel is open.
    if (event.key === 'ArrowDown') {
      const indexOfLastNotDisabled =
        this.options.length -
        1 -
        this.options
          .toArray()
          .reverse()
          .findIndex((opt) => !opt.disabled);

      // Do nothing if active option is already the last one.
      if (this.options.toArray().indexOf(this.activeOption) === indexOfLastNotDisabled) {
        return;
      }
      this._setActiveOption(
        this.options
          .toArray()
          .slice(this.options.toArray().indexOf(this.activeOption) + 1)
          .find((opt) => !opt.disabled)
      );
    } else if (event.key === 'ArrowUp') {
      if (!this.activeOption) {
        this._setActiveOption(this.options.last);
        return;
      }
      const indexOfFirstNotDisabled = this.options.toArray().findIndex((opt) => !opt.disabled);

      // Do nothing if active option is already the first one.
      if (this.options.toArray().indexOf(this.activeOption) === indexOfFirstNotDisabled) {
        return;
      }
      this._setActiveOption(
        this.options
          .toArray()
          .slice(0, this.options.toArray().indexOf(this.activeOption))
          .reverse()
          .find((opt) => !opt.disabled)
      );
    } else if ((event.key === 'Enter' || event.key === ' ') && this.activeOption) {
      this.onOptionClick(this.activeOption.value);
    } else if (event.key === 'Escape') {
      this.close();
    }
  };

  ngOnInit() {
    document.addEventListener('mousedown', this._closePanelOnClickOutside);
    document.addEventListener('touchstart', this._closePanelOnClickOutside);
  }

  ngAfterViewChecked() {
    //Initial setup of the selected Options
    if (this.multiple && typeof this.value === 'undefined') {
      this.value = [];
      this._changeDetectorRef.markForCheck();
    }
    this.selectedOptions = this.options.filter((opt) => this.value?.includes(opt.value));
    this._changeDetectorRef.detectChanges();
  }

  ngDoCheck() {
    const ngControl = this.ngControl;
    if (ngControl) {
      // The disabled state might go out of sync if the form group is swapped out.
      if (this._previousControl !== ngControl.control) {
        if (
          this._previousControl !== undefined &&
          ngControl.disabled !== null &&
          ngControl.disabled !== this.disabled
        ) {
          this.disabled = ngControl.disabled;
        }

        this._previousControl = ngControl.control;
      }
    }
  }

  ngOnDestroy(): void {
    this.stateChanges.complete();
    document.removeEventListener('mousedown', this._closePanelOnClickOutside);
    document.removeEventListener('touchstart', this._closePanelOnClickOutside);
  }
}
