/**
 * @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 {
  booleanAttribute,
  computed,
  Directive,
  effect,
  ElementRef,
  inject,
  input,
  InputSignal,
  InputSignalWithTransform,
  model,
  ModelSignal,
  OnDestroy,
  OutputRefSubscription,
  Signal,
  signal,
} from '@angular/core';
import {DateAdapter, MAT_DATE_FORMATS} from '@angular/material/core';
import {
  AbstractControl,
  ControlValueAccessor,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ValidationErrors,
  Validator,
  ValidatorFn,
  Validators,
} from '@angular/forms';
import {MAT_FORM_FIELD} from '@angular/material/form-field';
import {MatTimepicker} from './timepicker';
import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input';
import {Subscription} from 'rxjs';
import {DOWN_ARROW, ESCAPE, hasModifierKey, UP_ARROW} from '@angular/cdk/keycodes';
import {validateAdapter} from './util';
import {DOCUMENT} from '@angular/common';

/**
 * Input that can be used to enter time and connect to a `mat-timepicker`.
 */
@Directive({
  selector: 'input[matTimepicker]',
  exportAs: 'matTimepickerInput',
  host: {
    'class': 'mat-timepicker-input',
    'role': 'combobox',
    'type': 'text',
    'aria-haspopup': 'listbox',
    '[attr.aria-activedescendant]': '_ariaActiveDescendant()',
    '[attr.aria-expanded]': '_ariaExpanded()',
    '[attr.aria-controls]': '_ariaControls()',
    '[attr.mat-timepicker-id]': 'timepicker()?.panelId',
    '[disabled]': 'disabled()',
    '(blur)': '_handleBlur()',
    '(input)': '_handleInput($event.target.value)',
    '(keydown)': '_handleKeydown($event)',
  },
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: MatTimepickerInput,
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: MatTimepickerInput,
      multi: true,
    },
    {
      provide: MAT_INPUT_VALUE_ACCESSOR,
      useExisting: MatTimepickerInput,
    },
  ],
})
export class MatTimepickerInput<D> implements ControlValueAccessor, Validator, OnDestroy {
  private _elementRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
  private _document = inject(DOCUMENT);
  private _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!;
  private _formField = inject(MAT_FORM_FIELD, {optional: true});

  private _onChange: ((value: any) => void) | undefined;
  private _onTouched: (() => void) | undefined;
  private _validatorOnChange: (() => void) | undefined;
  private _accessorDisabled = signal(false);
  private _localeSubscription: Subscription;
  private _timepickerSubscription: OutputRefSubscription | undefined;
  private _validator: ValidatorFn;
  private _lastValueValid = true;
  private _lastValidDate: D | null = null;

  /** Value of the `aria-activedescendant` attribute. */
  protected readonly _ariaActiveDescendant = computed(() => {
    const timepicker = this.timepicker();
    const isOpen = timepicker.isOpen();
    const activeDescendant = timepicker.activeDescendant();
    return isOpen && activeDescendant ? activeDescendant : null;
  });

  /** Value of the `aria-expanded` attribute. */
  protected readonly _ariaExpanded = computed(() => this.timepicker().isOpen() + '');

  /** Value of the `aria-controls` attribute. */
  protected readonly _ariaControls = computed(() => {
    const timepicker = this.timepicker();
    return timepicker.isOpen() ? timepicker.panelId : null;
  });

  /** Current value of the input. */
  readonly value: ModelSignal<D | null> = model<D | null>(null);

  /** Timepicker that the input is associated with. */
  readonly timepicker: InputSignal<MatTimepicker<D>> = input.required<MatTimepicker<D>>({
    alias: 'matTimepicker',
  });

  /**
   * Minimum time that can be selected or typed in. Can be either
   * a date object (only time will be used) or a valid time string.
   */
  readonly min: InputSignalWithTransform<D | null, unknown> = input(null, {
    alias: 'matTimepickerMin',
    transform: (value: unknown) => this._transformDateInput<D>(value),
  });

  /**
   * Maximum time that can be selected or typed in. Can be either
   * a date object (only time will be used) or a valid time string.
   */
  readonly max: InputSignalWithTransform<D | null, unknown> = input(null, {
    alias: 'matTimepickerMax',
    transform: (value: unknown) => this._transformDateInput<D>(value),
  });

  /** Whether the input is disabled. */
  readonly disabled: Signal<boolean> = computed(
    () => this.disabledInput() || this._accessorDisabled(),
  );

  /**
   * Whether the input should be disabled through the template.
   * @docs-private
   */
  readonly disabledInput: InputSignalWithTransform<boolean, unknown> = input(false, {
    transform: booleanAttribute,
    alias: 'disabled',
  });

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

    this._validator = this._getValidator();
    this._respondToValueChanges();
    this._respondToMinMaxChanges();
    this._registerTimepicker();
    this._localeSubscription = this._dateAdapter.localeChanges.subscribe(() => {
      if (!this._hasFocus()) {
        this._formatValue(this.value());
      }
    });

    // Bind the click listener manually to the overlay origin, because we want the entire
    // form field to be clickable, if the timepicker is used in `mat-form-field`.
    this.getOverlayOrigin().nativeElement.addEventListener('click', this._handleClick);
  }

  /**
   * Implemented as a part of `ControlValueAccessor`.
   * @docs-private
   */
  writeValue(value: any): void {
    this.value.set(this._dateAdapter.getValidDateOrNull(value));
  }

  /**
   * Implemented as a part of `ControlValueAccessor`.
   * @docs-private
   */
  registerOnChange(fn: (value: any) => void): void {
    this._onChange = fn;
  }

  /**
   * Implemented as a part of `ControlValueAccessor`.
   * @docs-private
   */
  registerOnTouched(fn: () => void): void {
    this._onTouched = fn;
  }

  /**
   * Implemented as a part of `ControlValueAccessor`.
   * @docs-private
   */
  setDisabledState(isDisabled: boolean): void {
    this._accessorDisabled.set(isDisabled);
  }

  /**
   * Implemented as a part of `Validator`.
   * @docs-private
   */
  validate(control: AbstractControl): ValidationErrors | null {
    return this._validator(control);
  }

  /**
   * Implemented as a part of `Validator`.
   * @docs-private
   */
  registerOnValidatorChange(fn: () => void): void {
    this._validatorOnChange = fn;
  }

  /** Gets the element to which the timepicker popup should be attached. */
  getOverlayOrigin(): ElementRef<HTMLElement> {
    return this._formField?.getConnectedOverlayOrigin() || this._elementRef;
  }

  /** Focuses the input. */
  focus(): void {
    this._elementRef.nativeElement.focus();
  }

  ngOnDestroy(): void {
    this.getOverlayOrigin().nativeElement.removeEventListener('click', this._handleClick);
    this._timepickerSubscription?.unsubscribe();
    this._localeSubscription.unsubscribe();
  }

  /** Gets the ID of the input's label. */
  _getLabelId(): string | null {
    return this._formField?.getLabelId() || null;
  }

  /** Handles clicks on the input or the containing form field. */
  private _handleClick = (): void => {
    this.timepicker().open();
  };

  /** Handles the `input` event. */
  protected _handleInput(value: string) {
    const currentValue = this.value();
    const date = this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput);
    const hasChanged = !this._dateAdapter.sameTime(date, currentValue);

    if (!date || hasChanged || !!(value && !currentValue)) {
      // We need to fire the CVA change event for all nulls, otherwise the validators won't run.
      this._assignUserSelection(date, true);
    } else {
      // Call the validator even if the value hasn't changed since
      // some fields change depending on what the user has entered.
      this._validatorOnChange?.();
    }
  }

  /** Handles the `blur` event. */
  protected _handleBlur() {
    const value = this.value();

    // Only reformat on blur so the value doesn't change while the user is interacting.
    if (value && this._isValid(value)) {
      this._formatValue(value);
    }

    this._onTouched?.();
  }

  /** Handles the `keydown` event. */
  protected _handleKeydown(event: KeyboardEvent) {
    // All keyboard events while open are handled through the timepicker.
    if (this.timepicker().isOpen()) {
      return;
    }

    if (event.keyCode === ESCAPE && !hasModifierKey(event) && this.value() !== null) {
      event.preventDefault();
      this.value.set(null);
      this._formatValue(null);
    } else if ((event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) && !this.disabled()) {
      event.preventDefault();
      this.timepicker().open();
    }
  }

  /** Sets up the code that watches for changes in the value and adjusts the input. */
  private _respondToValueChanges(): void {
    effect(() => {
      const value = this._dateAdapter.deserialize(this.value());
      const wasValid = this._lastValueValid;
      this._lastValueValid = this._isValid(value);

      // Reformat the value if it changes while the user isn't interacting.
      if (!this._hasFocus()) {
        this._formatValue(value);
      }

      if (value && this._lastValueValid) {
        this._lastValidDate = value;
      }

      // Trigger the validator if the state changed.
      if (wasValid !== this._lastValueValid) {
        this._validatorOnChange?.();
      }
    });
  }

  /** Sets up the logic that registers the input with the timepicker. */
  private _registerTimepicker(): void {
    effect(() => {
      const timepicker = this.timepicker();
      timepicker.registerInput(this);
      timepicker.closed.subscribe(() => this._onTouched?.());
      timepicker.selected.subscribe(({value}) => {
        if (!this._dateAdapter.sameTime(value, this.value())) {
          this._assignUserSelection(value, true);
          this._formatValue(value);
        }
      });
    });
  }

  /** Sets up the logic that adjusts the input if the min/max changes. */
  private _respondToMinMaxChanges(): void {
    effect(() => {
      // Read the min/max so the effect knows when to fire.
      this.min();
      this.max();
      this._validatorOnChange?.();
    });
  }

  /**
   * Assigns a value set by the user to the input's model.
   * @param selection Time selected by the user that should be assigned.
   * @param propagateToAccessor Whether the value should be propagated to the ControlValueAccessor.
   */
  private _assignUserSelection(selection: D | null, propagateToAccessor: boolean) {
    if (selection == null || !this._isValid(selection)) {
      this.value.set(selection);
    } else {
      // If a datepicker and timepicker are writing to the same object and the user enters an
      // invalid time into the timepicker, we may end up clearing their selection from the
      // datepicker. If the user enters a valid time afterwards, the datepicker's selection will
      // have been lost. This logic restores the previously-valid date and sets its time to
      // the newly-selected time.
      const adapter = this._dateAdapter;
      const target = adapter.getValidDateOrNull(this._lastValidDate || this.value());
      const hours = adapter.getHours(selection);
      const minutes = adapter.getMinutes(selection);
      const seconds = adapter.getSeconds(selection);
      this.value.set(target ? adapter.setTime(target, hours, minutes, seconds) : selection);
    }

    if (propagateToAccessor) {
      this._onChange?.(this.value());
    }
  }

  /** Formats the current value and assigns it to the input. */
  private _formatValue(value: D | null): void {
    value = this._dateAdapter.getValidDateOrNull(value);
    this._elementRef.nativeElement.value =
      value == null ? '' : this._dateAdapter.format(value, this._dateFormats.display.timeInput);
  }

  /** Checks whether a value is valid. */
  private _isValid(value: D | null): boolean {
    return !value || this._dateAdapter.isValid(value);
  }

  /** Transforms an arbitrary value into a value that can be assigned to a date-based input. */
  private _transformDateInput<D>(value: unknown): D | null {
    const date =
      typeof value === 'string'
        ? this._dateAdapter.parseTime(value, this._dateFormats.parse.timeInput)
        : this._dateAdapter.deserialize(value);
    return date && this._dateAdapter.isValid(date) ? (date as D) : null;
  }

  /** Whether the input is currently focused. */
  private _hasFocus(): boolean {
    return this._document.activeElement === this._elementRef.nativeElement;
  }

  /** Gets a function that can be used to validate the input. */
  private _getValidator(): ValidatorFn {
    return Validators.compose([
      () =>
        this._lastValueValid
          ? null
          : {'matTimepickerParse': {'text': this._elementRef.nativeElement.value}},
      control => {
        const controlValue = this._dateAdapter.getValidDateOrNull(
          this._dateAdapter.deserialize(control.value),
        );
        const min = this.min();
        return !min || !controlValue || this._dateAdapter.compareTime(min, controlValue) <= 0
          ? null
          : {'matTimepickerMin': {'min': min, 'actual': controlValue}};
      },
      control => {
        const controlValue = this._dateAdapter.getValidDateOrNull(
          this._dateAdapter.deserialize(control.value),
        );
        const max = this.max();
        return !max || !controlValue || this._dateAdapter.compareTime(max, controlValue) >= 0
          ? null
          : {'matTimepickerMax': {'max': max, 'actual': controlValue}};
      },
    ])!;
  }
}
