/**
 * @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, coerceNumberProperty} from '@angular/cdk/coercion';
import {Platform} from '@angular/cdk/platform';
import {
  AfterViewInit,
  ContentChildren,
  Directive,
  ElementRef,
  inject,
  Input,
  NgZone,
  OnDestroy,
  QueryList,
  ANIMATION_MODULE_TYPE,
  Injector,
} from '@angular/core';
import {
  _StructuralStylesLoader,
  MAT_RIPPLE_GLOBAL_OPTIONS,
  RippleConfig,
  RippleGlobalOptions,
  RippleRenderer,
  RippleTarget,
} from '@angular/material/core';
import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
import {Subscription, merge} from 'rxjs';
import {
  MatListItemLine,
  MatListItemTitle,
  MatListItemIcon,
  MatListItemAvatar,
} from './list-item-sections';
import {MAT_LIST_CONFIG} from './tokens';

@Directive({
  host: {
    '[attr.aria-disabled]': 'disabled',
  },
})
/** @docs-private */
export abstract class MatListBase {
  _isNonInteractive: boolean = true;

  /** Whether ripples for all list items is disabled. */
  @Input()
  get disableRipple(): boolean {
    return this._disableRipple;
  }
  set disableRipple(value: BooleanInput) {
    this._disableRipple = coerceBooleanProperty(value);
  }
  private _disableRipple: boolean = false;

  /**
   * Whether the entire list is disabled. When disabled, the list itself and each of its list items
   * are disabled.
   */
  @Input()
  get disabled(): boolean {
    return this._disabled;
  }
  set disabled(value: BooleanInput) {
    this._disabled = coerceBooleanProperty(value);
  }
  private _disabled = false;

  protected _defaultOptions = inject(MAT_LIST_CONFIG, {optional: true});
}

@Directive({
  host: {
    '[class.mdc-list-item--disabled]': 'disabled',
    '[attr.aria-disabled]': 'disabled',
    '[attr.disabled]': '(_isButtonElement && disabled) || null',
  },
})
/** @docs-private */
export abstract class MatListItemBase implements AfterViewInit, OnDestroy, RippleTarget {
  _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
  protected _ngZone = inject(NgZone);
  private _listBase = inject(MatListBase, {optional: true});
  private _platform = inject(Platform);

  /** Query list matching list-item line elements. */
  abstract _lines: QueryList<MatListItemLine> | undefined;

  /** Query list matching list-item title elements. */
  abstract _titles: QueryList<MatListItemTitle> | undefined;

  /**
   * Element reference to the unscoped content in a list item.
   *
   * Unscoped content is user-projected text content in a list item that is
   * not part of an explicit line or title.
   */
  abstract _unscopedContent: ElementRef<HTMLSpanElement> | undefined;

  /** Host element for the list item. */
  _hostElement: HTMLElement;

  /** indicate whether the host element is a button or not */
  _isButtonElement: boolean;

  /** Whether animations are disabled. */
  _noopAnimations: boolean;

  @ContentChildren(MatListItemAvatar, {descendants: false}) _avatars: QueryList<never>;
  @ContentChildren(MatListItemIcon, {descendants: false}) _icons: QueryList<never>;

  /**
   * The number of lines this list item should reserve space for. If not specified,
   * lines are inferred based on the projected content.
   *
   * Explicitly specifying the number of lines is useful if you want to acquire additional
   * space and enable the wrapping of text. The unscoped text content of a list item will
   * always be able to take up the remaining space of the item, unless it represents the title.
   *
   * A maximum of three lines is supported as per the Material Design specification.
   */
  @Input()
  set lines(lines: number | string | null) {
    this._explicitLines = coerceNumberProperty(lines, null);
    this._updateItemLines(false);
  }
  _explicitLines: number | null = null;

  /** Whether ripples for list items are disabled. */
  @Input()
  get disableRipple(): boolean {
    return (
      this.disabled ||
      this._disableRipple ||
      this._noopAnimations ||
      !!this._listBase?.disableRipple
    );
  }
  set disableRipple(value: BooleanInput) {
    this._disableRipple = coerceBooleanProperty(value);
  }
  private _disableRipple: boolean = false;

  /** Whether the list-item is disabled. */
  @Input()
  get disabled(): boolean {
    return this._disabled || !!this._listBase?.disabled;
  }
  set disabled(value: BooleanInput) {
    this._disabled = coerceBooleanProperty(value);
  }
  private _disabled = false;

  private _subscriptions = new Subscription();
  private _rippleRenderer: RippleRenderer | null = null;

  /** Whether the list item has unscoped text content. */
  _hasUnscopedTextContent: boolean = false;

  /**
   * Implemented as part of `RippleTarget`.
   * @docs-private
   */
  rippleConfig: RippleConfig & RippleGlobalOptions;

  /**
   * Implemented as part of `RippleTarget`.
   * @docs-private
   */
  get rippleDisabled(): boolean {
    return this.disableRipple || !!this.rippleConfig.disabled;
  }

  constructor(...args: unknown[]);

  constructor() {
    inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader);
    const globalRippleOptions = inject<RippleGlobalOptions>(MAT_RIPPLE_GLOBAL_OPTIONS, {
      optional: true,
    });
    const animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});

    this.rippleConfig = globalRippleOptions || {};
    this._hostElement = this._elementRef.nativeElement;
    this._isButtonElement = this._hostElement.nodeName.toLowerCase() === 'button';
    this._noopAnimations = animationMode === 'NoopAnimations';

    if (this._listBase && !this._listBase._isNonInteractive) {
      this._initInteractiveListItem();
    }

    // If no type attribute is specified for a host `<button>` element, set it to `button`. If a
    // type attribute is already specified, we do nothing. We do this for backwards compatibility.
    // TODO: Determine if we intend to continue doing this for the MDC-based list.
    if (this._isButtonElement && !this._hostElement.hasAttribute('type')) {
      this._hostElement.setAttribute('type', 'button');
    }
  }

  ngAfterViewInit() {
    this._monitorProjectedLinesAndTitle();
    this._updateItemLines(true);
  }

  ngOnDestroy() {
    this._subscriptions.unsubscribe();
    if (this._rippleRenderer !== null) {
      this._rippleRenderer._removeTriggerEvents();
    }
  }

  /** Whether the list item has icons or avatars. */
  _hasIconOrAvatar() {
    return !!(this._avatars.length || this._icons.length);
  }

  private _initInteractiveListItem() {
    this._hostElement.classList.add('mat-mdc-list-item-interactive');
    this._rippleRenderer = new RippleRenderer(
      this,
      this._ngZone,
      this._hostElement,
      this._platform,
      inject(Injector),
    );
    this._rippleRenderer.setupTriggerEvents(this._hostElement);
  }

  /**
   * Subscribes to changes in the projected title and lines. Triggers a
   * item lines update whenever a change occurs.
   */
  private _monitorProjectedLinesAndTitle() {
    this._ngZone.runOutsideAngular(() => {
      this._subscriptions.add(
        merge(this._lines!.changes, this._titles!.changes).subscribe(() =>
          this._updateItemLines(false),
        ),
      );
    });
  }

  /**
   * Updates the lines of the list item. Based on the projected user content and optional
   * explicit lines setting, the visual appearance of the list item is determined.
   *
   * This method should be invoked whenever the projected user content changes, or
   * when the explicit lines have been updated.
   *
   * @param recheckUnscopedContent Whether the projected unscoped content should be re-checked.
   *   The unscoped content is not re-checked for every update as it is a rather expensive check
   *   for content that is expected to not change very often.
   */
  _updateItemLines(recheckUnscopedContent: boolean) {
    // If the updated is triggered too early before the view and content is initialized,
    // we just skip the update. After view initialization the update is triggered again.
    if (!this._lines || !this._titles || !this._unscopedContent) {
      return;
    }

    // Re-check the DOM for unscoped text content if requested. This needs to
    // happen before any computation or sanity checks run as these rely on the
    // result of whether there is unscoped text content or not.
    if (recheckUnscopedContent) {
      this._checkDomForUnscopedTextContent();
    }

    // Sanity check the list item lines and title in the content. This is a dev-mode only
    // check that can be dead-code eliminated by Terser in production.
    if (typeof ngDevMode === 'undefined' || ngDevMode) {
      sanityCheckListItemContent(this);
    }

    const numberOfLines = this._explicitLines ?? this._inferLinesFromContent();
    const unscopedContentEl = this._unscopedContent.nativeElement;

    // Update the list item element to reflect the number of lines.
    this._hostElement.classList.toggle('mat-mdc-list-item-single-line', numberOfLines <= 1);
    this._hostElement.classList.toggle('mdc-list-item--with-one-line', numberOfLines <= 1);
    this._hostElement.classList.toggle('mdc-list-item--with-two-lines', numberOfLines === 2);
    this._hostElement.classList.toggle('mdc-list-item--with-three-lines', numberOfLines === 3);

    // If there is no title and the unscoped content is the is the only line, the
    // unscoped text content will be treated as the title of the list-item.
    if (this._hasUnscopedTextContent) {
      const treatAsTitle = this._titles.length === 0 && numberOfLines === 1;
      unscopedContentEl.classList.toggle('mdc-list-item__primary-text', treatAsTitle);
      unscopedContentEl.classList.toggle('mdc-list-item__secondary-text', !treatAsTitle);
    } else {
      unscopedContentEl.classList.remove('mdc-list-item__primary-text');
      unscopedContentEl.classList.remove('mdc-list-item__secondary-text');
    }
  }

  /**
   * Infers the number of lines based on the projected user content. This is useful
   * if no explicit number of lines has been specified on the list item.
   *
   * The number of lines is inferred based on whether there is a title, the number of
   * additional lines (secondary/tertiary). An additional line is acquired if there is
   * unscoped text content.
   */
  private _inferLinesFromContent() {
    let numOfLines = this._titles!.length + this._lines!.length;
    if (this._hasUnscopedTextContent) {
      numOfLines += 1;
    }
    return numOfLines;
  }

  /** Checks whether the list item has unscoped text content. */
  private _checkDomForUnscopedTextContent() {
    this._hasUnscopedTextContent = Array.from<ChildNode>(
      this._unscopedContent!.nativeElement.childNodes,
    )
      .filter(node => node.nodeType !== node.COMMENT_NODE)
      .some(node => !!(node.textContent && node.textContent.trim()));
  }
}

/**
 * Sanity checks the configuration of the list item with respect to the amount
 * of lines, whether there is a title, or if there is unscoped text content.
 *
 * The checks are extracted into a top-level function that can be dead-code
 * eliminated by Terser or other optimizers in production mode.
 */
function sanityCheckListItemContent(item: MatListItemBase) {
  const numTitles = item._titles!.length;
  const numLines = item._lines!.length;

  if (numTitles > 1) {
    console.warn('A list item cannot have multiple titles.');
  }
  if (numTitles === 0 && numLines > 0) {
    console.warn('A list item line can only be used if there is a list item title.');
  }
  if (
    numTitles === 0 &&
    item._hasUnscopedTextContent &&
    item._explicitLines !== null &&
    item._explicitLines > 1
  ) {
    console.warn('A list item cannot have wrapping content without a title.');
  }
  if (numLines > 2 || (numLines === 2 && item._hasUnscopedTextContent)) {
    console.warn('A list item can have at maximum three lines.');
  }
}
