/**
 * @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 {
  Directive,
  ElementRef,
  InjectionToken,
  Input,
  OnDestroy,
  OnInit,
  QueryList,
  booleanAttribute,
  inject,
} from '@angular/core';

/**
 * Item inside a tab header relative to which the ink bar can be aligned.
 * @docs-private
 */
export interface MatInkBarItem extends OnInit, OnDestroy {
  elementRef: ElementRef<HTMLElement>;
  activateInkBar(previousIndicatorClientRect?: DOMRect): void;
  deactivateInkBar(): void;
  fitInkBarToContent: boolean;
}

/** Class that is applied when a tab indicator is active. */
const ACTIVE_CLASS = 'mdc-tab-indicator--active';

/** Class that is applied when the tab indicator should not transition. */
const NO_TRANSITION_CLASS = 'mdc-tab-indicator--no-transition';

/**
 * Abstraction around the MDC tab indicator that acts as the tab header's ink bar.
 * @docs-private
 */
export class MatInkBar {
  /** Item to which the ink bar is aligned currently. */
  private _currentItem: MatInkBarItem | undefined;

  constructor(private _items: QueryList<MatInkBarItem>) {}

  /** Hides the ink bar. */
  hide() {
    this._items.forEach(item => item.deactivateInkBar());
    this._currentItem = undefined;
  }

  /** Aligns the ink bar to a DOM node. */
  alignToElement(element: HTMLElement) {
    const correspondingItem = this._items.find(item => item.elementRef.nativeElement === element);
    const currentItem = this._currentItem;

    if (correspondingItem === currentItem) {
      return;
    }

    currentItem?.deactivateInkBar();

    if (correspondingItem) {
      const domRect = currentItem?.elementRef.nativeElement.getBoundingClientRect?.();

      // The ink bar won't animate unless we give it the `DOMRect` of the previous item.
      correspondingItem.activateInkBar(domRect);
      this._currentItem = correspondingItem;
    }
  }
}

@Directive()
export abstract class InkBarItem implements OnInit, OnDestroy {
  private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
  private _inkBarElement: HTMLElement | null;
  private _inkBarContentElement: HTMLElement | null;
  private _fitToContent = false;

  /** Whether the ink bar should fit to the entire tab or just its content. */
  @Input({transform: booleanAttribute})
  get fitInkBarToContent(): boolean {
    return this._fitToContent;
  }
  set fitInkBarToContent(newValue: boolean) {
    if (this._fitToContent !== newValue) {
      this._fitToContent = newValue;

      if (this._inkBarElement) {
        this._appendInkBarElement();
      }
    }
  }

  /** Aligns the ink bar to the current item. */
  activateInkBar(previousIndicatorClientRect?: DOMRect) {
    const element = this._elementRef.nativeElement;

    // Early exit if no indicator is present to handle cases where an indicator
    // may be activated without a prior indicator state
    if (
      !previousIndicatorClientRect ||
      !element.getBoundingClientRect ||
      !this._inkBarContentElement
    ) {
      element.classList.add(ACTIVE_CLASS);
      return;
    }

    // This animation uses the FLIP approach. You can read more about it at the link below:
    // https://aerotwist.com/blog/flip-your-animations/

    // Calculate the dimensions based on the dimensions of the previous indicator
    const currentClientRect = element.getBoundingClientRect();
    const widthDelta = previousIndicatorClientRect.width / currentClientRect.width;
    const xPosition = previousIndicatorClientRect.left - currentClientRect.left;
    element.classList.add(NO_TRANSITION_CLASS);
    this._inkBarContentElement.style.setProperty(
      'transform',
      `translateX(${xPosition}px) scaleX(${widthDelta})`,
    );

    // Force repaint before updating classes and transform to ensure the transform properly takes effect
    element.getBoundingClientRect();

    element.classList.remove(NO_TRANSITION_CLASS);
    element.classList.add(ACTIVE_CLASS);
    this._inkBarContentElement.style.setProperty('transform', '');
  }

  /** Removes the ink bar from the current item. */
  deactivateInkBar() {
    this._elementRef.nativeElement.classList.remove(ACTIVE_CLASS);
  }

  /** Initializes the foundation. */
  ngOnInit() {
    this._createInkBarElement();
  }

  /** Destroys the foundation. */
  ngOnDestroy() {
    this._inkBarElement?.remove();
    this._inkBarElement = this._inkBarContentElement = null!;
  }

  /** Creates and appends the ink bar element. */
  private _createInkBarElement() {
    const documentNode = this._elementRef.nativeElement.ownerDocument || document;
    const inkBarElement = (this._inkBarElement = documentNode.createElement('span'));
    const inkBarContentElement = (this._inkBarContentElement = documentNode.createElement('span'));

    inkBarElement.className = 'mdc-tab-indicator';
    inkBarContentElement.className =
      'mdc-tab-indicator__content mdc-tab-indicator__content--underline';

    inkBarElement.appendChild(this._inkBarContentElement);
    this._appendInkBarElement();
  }

  /**
   * Appends the ink bar to the tab host element or content, depending on whether
   * the ink bar should fit to content.
   */
  private _appendInkBarElement() {
    if (!this._inkBarElement && (typeof ngDevMode === 'undefined' || ngDevMode)) {
      throw Error('Ink bar element has not been created and cannot be appended');
    }

    const parentElement = this._fitToContent
      ? this._elementRef.nativeElement.querySelector('.mdc-tab__content')
      : this._elementRef.nativeElement;

    if (!parentElement && (typeof ngDevMode === 'undefined' || ngDevMode)) {
      throw Error('Missing element to host the ink bar');
    }

    parentElement!.appendChild(this._inkBarElement!);
  }
}

/**
 * Interface for a MatInkBar positioner method, defining the positioning and width of the ink
 * bar in a set of tabs.
 */
export interface _MatInkBarPositioner {
  (element: HTMLElement): {left: string; width: string};
}

/**
 * The default positioner function for the MatInkBar.
 * @docs-private
 */
export function _MAT_INK_BAR_POSITIONER_FACTORY(): _MatInkBarPositioner {
  const method = (element: HTMLElement) => ({
    left: element ? (element.offsetLeft || 0) + 'px' : '0',
    width: element ? (element.offsetWidth || 0) + 'px' : '0',
  });

  return method;
}

/** Injection token for the MatInkBar's Positioner. */
export const _MAT_INK_BAR_POSITIONER = new InjectionToken<_MatInkBarPositioner>(
  'MatInkBarPositioner',
  {
    providedIn: 'root',
    factory: _MAT_INK_BAR_POSITIONER_FACTORY,
  },
);
