/**
 * @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 {
  afterNextRender,
  AfterRenderRef,
  booleanAttribute,
  ChangeDetectionStrategy,
  Component,
  effect,
  ElementRef,
  inject,
  Injector,
  input,
  InputSignal,
  InputSignalWithTransform,
  OnDestroy,
  output,
  OutputEmitterRef,
  Signal,
  signal,
  TemplateRef,
  untracked,
  viewChild,
  viewChildren,
  ViewContainerRef,
  ViewEncapsulation,
} from '@angular/core';
import {animate, group, state, style, transition, trigger} from '@angular/animations';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MAT_OPTION_PARENT_COMPONENT,
  MatOption,
  MatOptionParentComponent,
} from '@angular/material/core';
import {Directionality} from '@angular/cdk/bidi';
import {Overlay, OverlayRef} from '@angular/cdk/overlay';
import {TemplatePortal} from '@angular/cdk/portal';
import {_getEventTarget} from '@angular/cdk/platform';
import {ENTER, ESCAPE, hasModifierKey, TAB} from '@angular/cdk/keycodes';
import {_IdGenerator, ActiveDescendantKeyManager} from '@angular/cdk/a11y';
import type {MatTimepickerInput} from './timepicker-input';
import {
  generateOptions,
  MAT_TIMEPICKER_CONFIG,
  MatTimepickerOption,
  parseInterval,
  validateAdapter,
} from './util';
import {Subscription} from 'rxjs';

/** Event emitted when a value is selected in the timepicker. */
export interface MatTimepickerSelected<D> {
  value: D;
  source: MatTimepicker<D>;
}

/**
 * Renders out a listbox that can be used to select a time of day.
 * Intended to be used together with `MatTimepickerInput`.
 */
@Component({
  selector: 'mat-timepicker',
  exportAs: 'matTimepicker',
  templateUrl: 'timepicker.html',
  styleUrl: 'timepicker.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [MatOption],
  providers: [
    {
      provide: MAT_OPTION_PARENT_COMPONENT,
      useExisting: MatTimepicker,
    },
  ],
  animations: [
    trigger('panel', [
      state('void', style({opacity: 0, transform: 'scaleY(0.8)'})),
      transition(':enter', [
        group([
          animate('0.03s linear', style({opacity: 1})),
          animate('0.12s cubic-bezier(0, 0, 0.2, 1)', style({transform: 'scaleY(1)'})),
        ]),
      ]),
      transition(':leave', [animate('0.075s linear', style({opacity: 0}))]),
    ]),
  ],
})
export class MatTimepicker<D> implements OnDestroy, MatOptionParentComponent {
  private _overlay = inject(Overlay);
  private _dir = inject(Directionality, {optional: true});
  private _viewContainerRef = inject(ViewContainerRef);
  private _injector = inject(Injector);
  private _defaultConfig = inject(MAT_TIMEPICKER_CONFIG, {optional: true});
  private _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!;

  private _isOpen = signal(false);
  private _activeDescendant = signal<string | null>(null);

  private _input: MatTimepickerInput<D>;
  private _overlayRef: OverlayRef | null = null;
  private _portal: TemplatePortal<unknown> | null = null;
  private _optionsCacheKey: string | null = null;
  private _localeChanges: Subscription;
  private _onOpenRender: AfterRenderRef | null = null;

  protected _panelTemplate = viewChild.required<TemplateRef<unknown>>('panelTemplate');
  protected _timeOptions: readonly MatTimepickerOption<D>[] = [];
  protected _options = viewChildren(MatOption);

  private _keyManager = new ActiveDescendantKeyManager(this._options, this._injector)
    .withHomeAndEnd(true)
    .withPageUpDown(true)
    .withVerticalOrientation(true);

  /**
   * Interval between each option in the timepicker. The value can either be an amount of
   * seconds (e.g. 90) or a number with a unit (e.g. 45m). Supported units are `s` for seconds,
   * `m` for minutes or `h` for hours.
   */
  readonly interval: InputSignalWithTransform<number | null, number | string | null> = input(
    parseInterval(this._defaultConfig?.interval || null),
    {transform: parseInterval},
  );

  /**
   * Array of pre-defined options that the user can select from, as an alternative to using the
   * `interval` input. An error will be thrown if both `options` and `interval` are specified.
   */
  readonly options: InputSignal<readonly MatTimepickerOption<D>[] | null> = input<
    readonly MatTimepickerOption<D>[] | null
  >(null);

  /** Whether the timepicker is open. */
  readonly isOpen: Signal<boolean> = this._isOpen.asReadonly();

  /** Emits when the user selects a time. */
  readonly selected: OutputEmitterRef<MatTimepickerSelected<D>> = output();

  /** Emits when the timepicker is opened. */
  readonly opened: OutputEmitterRef<void> = output();

  /** Emits when the timepicker is closed. */
  readonly closed: OutputEmitterRef<void> = output();

  /** ID of the active descendant option. */
  readonly activeDescendant: Signal<string | null> = this._activeDescendant.asReadonly();

  /** Unique ID of the timepicker's panel */
  readonly panelId: string = inject(_IdGenerator).getId('mat-timepicker-panel-');

  /** Whether ripples within the timepicker should be disabled. */
  readonly disableRipple: InputSignalWithTransform<boolean, unknown> = input(
    this._defaultConfig?.disableRipple ?? false,
    {
      transform: booleanAttribute,
    },
  );

  /** ARIA label for the timepicker panel. */
  readonly ariaLabel: InputSignal<string | null> = input<string | null>(null, {
    alias: 'aria-label',
  });

  /** ID of the label element for the timepicker panel. */
  readonly ariaLabelledby: InputSignal<string | null> = input<string | null>(null, {
    alias: 'aria-labelledby',
  });

  constructor() {
    if (typeof ngDevMode === 'undefined' || ngDevMode) {
      validateAdapter(this._dateAdapter, this._dateFormats);

      effect(() => {
        const options = this.options();
        const interval = this.interval();

        if (options !== null && interval !== null) {
          throw new Error(
            'Cannot specify both the `options` and `interval` inputs at the same time',
          );
        } else if (options?.length === 0) {
          throw new Error('Value of `options` input cannot be an empty array');
        }
      });
    }

    // Since the panel ID is static, we can set it once without having to maintain a host binding.
    const element = inject<ElementRef<HTMLElement>>(ElementRef);
    element.nativeElement.setAttribute('mat-timepicker-panel-id', this.panelId);
    this._handleLocaleChanges();
    this._handleInputStateChanges();
    this._keyManager.change.subscribe(() =>
      this._activeDescendant.set(this._keyManager.activeItem?.id || null),
    );
  }

  /** Opens the timepicker. */
  open(): void {
    if (!this._input) {
      return;
    }

    // Focus should already be on the input, but this call is in case the timepicker is opened
    // programmatically. We need to call this even if the timepicker is already open, because
    // the user might be clicking the toggle.
    this._input.focus();

    if (this._isOpen()) {
      return;
    }

    this._isOpen.set(true);
    this._generateOptions();
    const overlayRef = this._getOverlayRef();
    overlayRef.updateSize({width: this._input.getOverlayOrigin().nativeElement.offsetWidth});
    this._portal ??= new TemplatePortal(this._panelTemplate(), this._viewContainerRef);
    overlayRef.attach(this._portal);
    this._onOpenRender?.destroy();
    this._onOpenRender = afterNextRender(
      () => {
        const options = this._options();
        this._syncSelectedState(this._input.value(), options, options[0]);
        this._onOpenRender = null;
      },
      {injector: this._injector},
    );

    this.opened.emit();
  }

  /** Closes the timepicker. */
  close(): void {
    if (this._isOpen()) {
      this._isOpen.set(false);
      this._overlayRef?.detach();
      this.closed.emit();
    }
  }

  /** Registers an input with the timepicker. */
  registerInput(input: MatTimepickerInput<D>): void {
    if (this._input && input !== this._input && (typeof ngDevMode === 'undefined' || ngDevMode)) {
      throw new Error('MatTimepicker can only be registered with one input at a time');
    }

    this._input = input;
  }

  ngOnDestroy(): void {
    this._keyManager.destroy();
    this._localeChanges.unsubscribe();
    this._onOpenRender?.destroy();
    this._overlayRef?.dispose();
  }

  /** Selects a specific time value. */
  protected _selectValue(value: D) {
    this.close();
    this.selected.emit({value, source: this});
    this._input.focus();
  }

  /** Gets the value of the `aria-labelledby` attribute. */
  protected _getAriaLabelledby(): string | null {
    if (this.ariaLabel()) {
      return null;
    }
    return this.ariaLabelledby() || this._input?._getLabelId() || null;
  }

  /** Creates an overlay reference for the timepicker panel. */
  private _getOverlayRef(): OverlayRef {
    if (this._overlayRef) {
      return this._overlayRef;
    }

    const positionStrategy = this._overlay
      .position()
      .flexibleConnectedTo(this._input.getOverlayOrigin())
      .withFlexibleDimensions(false)
      .withPush(false)
      .withTransformOriginOn('.mat-timepicker-panel')
      .withPositions([
        {
          originX: 'start',
          originY: 'bottom',
          overlayX: 'start',
          overlayY: 'top',
        },
        {
          originX: 'start',
          originY: 'top',
          overlayX: 'start',
          overlayY: 'bottom',
          panelClass: 'mat-timepicker-above',
        },
      ]);

    this._overlayRef = this._overlay.create({
      positionStrategy,
      scrollStrategy: this._overlay.scrollStrategies.reposition(),
      direction: this._dir || 'ltr',
      hasBackdrop: false,
    });

    this._overlayRef.keydownEvents().subscribe(event => {
      this._handleKeydown(event);
    });

    this._overlayRef.outsidePointerEvents().subscribe(event => {
      const target = _getEventTarget(event) as HTMLElement;
      const origin = this._input.getOverlayOrigin().nativeElement;

      if (target && target !== origin && !origin.contains(target)) {
        this.close();
      }
    });

    return this._overlayRef;
  }

  /** Generates the list of options from which the user can select.. */
  private _generateOptions(): void {
    // Default the interval to 30 minutes.
    const interval = this.interval() ?? 30 * 60;
    const options = this.options();

    if (options !== null) {
      this._timeOptions = options;
    } else {
      const adapter = this._dateAdapter;
      const timeFormat = this._dateFormats.display.timeInput;
      const min = this._input.min() || adapter.setTime(adapter.today(), 0, 0, 0);
      const max = this._input.max() || adapter.setTime(adapter.today(), 23, 59, 0);
      const cacheKey =
        interval + '/' + adapter.format(min, timeFormat) + '/' + adapter.format(max, timeFormat);

      // Don't re-generate the options if the inputs haven't changed.
      if (cacheKey !== this._optionsCacheKey) {
        this._optionsCacheKey = cacheKey;
        this._timeOptions = generateOptions(adapter, this._dateFormats, min, max, interval);
      }
    }
  }

  /**
   * Synchronizes the internal state of the component based on a specific selected date.
   * @param value Currently selected date.
   * @param options Options rendered out in the timepicker.
   * @param fallback Option to set as active if no option is selected.
   */
  private _syncSelectedState(
    value: D | null,
    options: readonly MatOption[],
    fallback: MatOption | null,
  ): void {
    let hasSelected = false;

    for (const option of options) {
      if (value && this._dateAdapter.sameTime(option.value, value)) {
        option.select(false);
        scrollOptionIntoView(option, 'center');
        untracked(() => this._keyManager.setActiveItem(option));
        hasSelected = true;
      } else {
        option.deselect(false);
      }
    }

    // If no option was selected, we need to reset the key manager since
    // it might be holding onto an option that no longer exists.
    if (!hasSelected) {
      if (fallback) {
        untracked(() => this._keyManager.setActiveItem(fallback));
        scrollOptionIntoView(fallback, 'center');
      } else {
        untracked(() => this._keyManager.setActiveItem(-1));
      }
    }
  }

  /** Handles keyboard events while the overlay is open. */
  private _handleKeydown(event: KeyboardEvent): void {
    const keyCode = event.keyCode;

    if (keyCode === TAB) {
      this.close();
    } else if (keyCode === ESCAPE && !hasModifierKey(event)) {
      event.preventDefault();
      this.close();
    } else if (keyCode === ENTER) {
      event.preventDefault();

      if (this._keyManager.activeItem) {
        this._selectValue(this._keyManager.activeItem.value);
      } else {
        this.close();
      }
    } else {
      const previousActive = this._keyManager.activeItem;
      this._keyManager.onKeydown(event);
      const currentActive = this._keyManager.activeItem;

      if (currentActive && currentActive !== previousActive) {
        scrollOptionIntoView(currentActive, 'nearest');
      }
    }
  }

  /** Sets up the logic that updates the timepicker when the locale changes. */
  private _handleLocaleChanges(): void {
    // Re-generate the options list if the locale changes.
    this._localeChanges = this._dateAdapter.localeChanges.subscribe(() => {
      this._optionsCacheKey = null;

      if (this.isOpen()) {
        this._generateOptions();
      }
    });
  }

  /**
   * Sets up the logic that updates the timepicker when the state of the connected input changes.
   */
  private _handleInputStateChanges(): void {
    effect(() => {
      const value = this._input?.value();
      const options = this._options();

      if (this._isOpen()) {
        this._syncSelectedState(value, options, null);
      }
    });
  }
}

/**
 * Scrolls an option into view.
 * @param option Option to be scrolled into view.
 * @param position Position to which to align the option relative to the scrollable container.
 */
function scrollOptionIntoView(option: MatOption, position: ScrollLogicalPosition) {
  option._getHostElement().scrollIntoView({block: position, inline: position});
}
