/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {SelectionModel} from '@angular/cdk/collections';
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  ElementRef,
  EventEmitter,
  InjectionToken,
  Input,
  OnDestroy,
  OnInit,
  Output,
  QueryList,
  ViewChild,
  ViewEncapsulation,
  inject,
} from '@angular/core';
import {ThemePalette} from '@angular/material/core';
import {MatListBase, MatListItemBase} from './list-base';
import {LIST_OPTION, ListOption, MatListOptionTogglePosition} from './list-option-types';
import {MatListItemLine, MatListItemTitle} from './list-item-sections';
import {NgTemplateOutlet} from '@angular/common';
import {CdkObserveContent} from '@angular/cdk/observers';

/**
 * Injection token that can be used to reference instances of an `SelectionList`. It serves
 * as alternative token to an actual implementation which would result in circular references.
 * @docs-private
 */
export const SELECTION_LIST = new InjectionToken<SelectionList>('SelectionList');

/**
 * Interface describing the containing list of a list option. This is used to avoid
 * circular dependencies between the list-option and the selection list.
 * @docs-private
 */
export interface SelectionList extends MatListBase {
  multiple: boolean;
  color: ThemePalette;
  selectedOptions: SelectionModel<MatListOption>;
  hideSingleSelectionIndicator: boolean;
  compareWith: (o1: any, o2: any) => boolean;
  _value: string[] | null;
  _reportValueChange(): void;
  _emitChangeEvent(options: MatListOption[]): void;
  _onTouched(): void;
}

@Component({
  selector: 'mat-list-option',
  exportAs: 'matListOption',
  styleUrl: 'list-option.css',
  host: {
    'class': 'mat-mdc-list-item mat-mdc-list-option mdc-list-item',
    'role': 'option',
    // As per MDC, only list items without checkbox or radio indicator should receive the
    // `--selected` class.
    '[class.mdc-list-item--selected]':
      'selected && !_selectionList.multiple && _selectionList.hideSingleSelectionIndicator',
    // Based on the checkbox/radio position and whether there are icons or avatars, we apply MDC's
    // list-item `--leading` and `--trailing` classes.
    '[class.mdc-list-item--with-leading-avatar]': '_hasProjected("avatars", "before")',
    '[class.mdc-list-item--with-leading-icon]': '_hasProjected("icons", "before")',
    '[class.mdc-list-item--with-trailing-icon]': '_hasProjected("icons", "after")',
    '[class.mat-mdc-list-option-with-trailing-avatar]': '_hasProjected("avatars", "after")',
    // Based on the checkbox/radio position, we apply the `--leading` or `--trailing` MDC classes
    // which ensure that the checkbox/radio is positioned correctly within the list item.
    '[class.mdc-list-item--with-leading-checkbox]': '_hasCheckboxAt("before")',
    '[class.mdc-list-item--with-trailing-checkbox]': '_hasCheckboxAt("after")',
    '[class.mdc-list-item--with-leading-radio]': '_hasRadioAt("before")',
    '[class.mdc-list-item--with-trailing-radio]': '_hasRadioAt("after")',

    // Utility class that makes it easier to target the case where there's both a leading
    // and a trailing icon. Avoids having to write out all the combinations.
    '[class.mat-mdc-list-item-both-leading-and-trailing]': '_hasBothLeadingAndTrailing()',
    '[class.mat-accent]': 'color !== "primary" && color !== "warn"',
    '[class.mat-warn]': 'color === "warn"',
    '[class._mat-animation-noopable]': '_noopAnimations',
    '[attr.aria-selected]': 'selected',
    '(blur)': '_handleBlur()',
    '(click)': '_toggleOnInteraction()',
  },
  templateUrl: 'list-option.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    {provide: MatListItemBase, useExisting: MatListOption},
    {provide: LIST_OPTION, useExisting: MatListOption},
  ],
  imports: [NgTemplateOutlet, CdkObserveContent],
})
export class MatListOption extends MatListItemBase implements ListOption, OnInit, OnDestroy {
  private _selectionList = inject<SelectionList>(SELECTION_LIST);
  private _changeDetectorRef = inject(ChangeDetectorRef);

  @ContentChildren(MatListItemLine, {descendants: true}) _lines: QueryList<MatListItemLine>;
  @ContentChildren(MatListItemTitle, {descendants: true}) _titles: QueryList<MatListItemTitle>;
  @ViewChild('unscopedContent') _unscopedContent: ElementRef<HTMLSpanElement>;

  /**
   * Emits when the selected state of the option has changed.
   * Use to facilitate two-data binding to the `selected` property.
   * @docs-private
   */
  @Output()
  readonly selectedChange: EventEmitter<boolean> = new EventEmitter<boolean>();

  /** Whether the label should appear before or after the checkbox/radio. Defaults to 'after' */
  @Input() togglePosition: MatListOptionTogglePosition = 'after';

  /**
   * Whether the label should appear before or after the checkbox/radio. Defaults to 'after'
   *
   * @deprecated Use `togglePosition` instead.
   * @breaking-change 17.0.0
   */
  @Input() get checkboxPosition(): MatListOptionTogglePosition {
    return this.togglePosition;
  }
  set checkboxPosition(value: MatListOptionTogglePosition) {
    this.togglePosition = value;
  }

  /**
   * Theme color of the list option. This sets the color of the checkbox/radio.
   * This API is supported in M2 themes only, it has no effect in M3 themes.
   *
   * For information on applying color variants in M3, see
   * https://material.angular.io/guide/theming#using-component-color-variants.
   */
  @Input()
  get color(): ThemePalette {
    return this._color || this._selectionList.color;
  }
  set color(newValue: ThemePalette) {
    this._color = newValue;
  }
  private _color: ThemePalette;

  /** Value of the option */
  @Input()
  get value(): any {
    return this._value;
  }
  set value(newValue: any) {
    if (this.selected && newValue !== this.value && this._inputsInitialized) {
      this.selected = false;
    }

    this._value = newValue;
  }
  private _value: any;

  /** Whether the option is selected. */
  @Input()
  get selected(): boolean {
    return this._selectionList.selectedOptions.isSelected(this);
  }
  set selected(value: BooleanInput) {
    const isSelected = coerceBooleanProperty(value);

    if (isSelected !== this._selected) {
      this._setSelected(isSelected);

      if (isSelected || this._selectionList.multiple) {
        this._selectionList._reportValueChange();
      }
    }
  }
  private _selected = false;

  /**
   * This is set to true after the first OnChanges cycle so we don't
   * clear the value of `selected` in the first cycle.
   */
  private _inputsInitialized = false;

  ngOnInit() {
    const list = this._selectionList;

    if (list._value && list._value.some(value => list.compareWith(this._value, value))) {
      this._setSelected(true);
    }

    const wasSelected = this._selected;

    // List options that are selected at initialization can't be reported properly to the form
    // control. This is because it takes some time until the selection-list knows about all
    // available options. Also it can happen that the ControlValueAccessor has an initial value
    // that should be used instead. Deferring the value change report to the next tick ensures
    // that the form control value is not being overwritten.
    Promise.resolve().then(() => {
      if (this._selected || wasSelected) {
        this.selected = true;
        this._changeDetectorRef.markForCheck();
      }
    });
    this._inputsInitialized = true;
  }

  override ngOnDestroy(): void {
    super.ngOnDestroy();

    if (this.selected) {
      // We have to delay this until the next tick in order
      // to avoid changed after checked errors.
      Promise.resolve().then(() => {
        this.selected = false;
      });
    }
  }

  /** Toggles the selection state of the option. */
  toggle(): void {
    this.selected = !this.selected;
  }

  /** Allows for programmatic focusing of the option. */
  focus(): void {
    this._hostElement.focus();
  }

  /** Gets the text label of the list option. Used for the typeahead functionality in the list. */
  getLabel() {
    const titleElement = this._titles?.get(0)?._elementRef.nativeElement;
    // If there is no explicit title element, the unscoped text content
    // is treated as the list item title.
    const labelEl = titleElement || this._unscopedContent?.nativeElement;
    return labelEl?.textContent || '';
  }

  /** Whether a checkbox is shown at the given position. */
  _hasCheckboxAt(position: MatListOptionTogglePosition): boolean {
    return this._selectionList.multiple && this._getTogglePosition() === position;
  }

  /** Where a radio indicator is shown at the given position. */
  _hasRadioAt(position: MatListOptionTogglePosition): boolean {
    return (
      !this._selectionList.multiple &&
      this._getTogglePosition() === position &&
      !this._selectionList.hideSingleSelectionIndicator
    );
  }

  /** Whether icons or avatars are shown at the given position. */
  _hasIconsOrAvatarsAt(position: 'before' | 'after'): boolean {
    return this._hasProjected('icons', position) || this._hasProjected('avatars', position);
  }

  /** Gets whether the given type of element is projected at the specified position. */
  _hasProjected(type: 'icons' | 'avatars', position: 'before' | 'after'): boolean {
    // If the checkbox/radio is shown at the specified position, neither icons or
    // avatars can be shown at the position.
    return (
      this._getTogglePosition() !== position &&
      (type === 'avatars' ? this._avatars.length !== 0 : this._icons.length !== 0)
    );
  }

  _handleBlur() {
    this._selectionList._onTouched();
  }

  /** Gets the current position of the checkbox/radio. */
  _getTogglePosition() {
    return this.togglePosition || 'after';
  }

  /**
   * Sets the selected state of the option.
   * @returns Whether the value has changed.
   */
  _setSelected(selected: boolean): boolean {
    if (selected === this._selected) {
      return false;
    }

    this._selected = selected;

    if (selected) {
      this._selectionList.selectedOptions.select(this);
    } else {
      this._selectionList.selectedOptions.deselect(this);
    }

    this.selectedChange.emit(selected);
    this._changeDetectorRef.markForCheck();
    return true;
  }

  /**
   * Notifies Angular that the option needs to be checked in the next change detection run.
   * Mainly used to trigger an update of the list option if the disabled state of the selection
   * list changed.
   */
  _markForCheck() {
    this._changeDetectorRef.markForCheck();
  }

  /** Toggles the option's value based on a user interaction. */
  _toggleOnInteraction() {
    if (!this.disabled) {
      if (this._selectionList.multiple) {
        this.selected = !this.selected;
        this._selectionList._emitChangeEvent([this]);
      } else if (!this.selected) {
        this.selected = true;
        this._selectionList._emitChangeEvent([this]);
      }
    }
  }

  /** Sets the tabindex of the list option. */
  _setTabindex(value: number) {
    this._hostElement.setAttribute('tabindex', value + '');
  }

  protected _hasBothLeadingAndTrailing(): boolean {
    const hasLeading =
      this._hasProjected('avatars', 'before') ||
      this._hasProjected('icons', 'before') ||
      this._hasCheckboxAt('before') ||
      this._hasRadioAt('before');
    const hasTrailing =
      this._hasProjected('icons', 'after') ||
      this._hasProjected('avatars', 'after') ||
      this._hasCheckboxAt('after') ||
      this._hasRadioAt('after');
    return hasLeading && hasTrailing;
  }
}
