/**
 * @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.io/license
 */

import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {Platform} from '@angular/cdk/platform';
import {
  AfterContentInit,
  Attribute,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  forwardRef,
  Input,
  OnDestroy,
  Output,
  ViewChild,
  ViewEncapsulation
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {
  applyCssTransform,
  CanColor,
  CanDisable,
  CanDisableRipple,
  HammerInput,
  HasTabIndex,
  MatRipple,
  mixinColor,
  mixinDisabled,
  mixinDisableRipple,
  mixinTabIndex,
  RippleConfig,
  RippleRef,
} from '@angular/material/core';

// Increasing integer for generating unique ids for slide-toggle components.
let nextUniqueId = 0;

export const MAT_SLIDE_TOGGLE_VALUE_ACCESSOR: any = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => MatSlideToggle),
  multi: true
};

/** Change event object emitted by a MatSlideToggle. */
export class MatSlideToggleChange {
  source: MatSlideToggle;
  checked: boolean;
}

// Boilerplate for applying mixins to MatSlideToggle.
/** @docs-private */
export class MatSlideToggleBase {
  constructor(public _elementRef: ElementRef) {}
}
export const _MatSlideToggleMixinBase =
  mixinTabIndex(mixinColor(mixinDisableRipple(mixinDisabled(MatSlideToggleBase)), 'accent'));

/** Represents a slidable "switch" toggle that can be moved between on and off. */
@Component({
  moduleId: module.id,
  selector: 'mat-slide-toggle',
  exportAs: 'matSlideToggle',
  host: {
    'class': 'mat-slide-toggle',
    '[id]': 'id',
    '[class.mat-checked]': 'checked',
    '[class.mat-disabled]': 'disabled',
    '[class.mat-slide-toggle-label-before]': 'labelPosition == "before"',
  },
  templateUrl: 'slide-toggle.html',
  styleUrls: ['slide-toggle.css'],
  providers: [MAT_SLIDE_TOGGLE_VALUE_ACCESSOR],
  inputs: ['disabled', 'disableRipple', 'color', 'tabIndex'],
  encapsulation: ViewEncapsulation.None,
  preserveWhitespaces: false,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatSlideToggle extends _MatSlideToggleMixinBase implements OnDestroy, AfterContentInit,
    ControlValueAccessor, CanDisable, CanColor, HasTabIndex, CanDisableRipple {

  private onChange = (_: any) => {};
  private onTouched = () => {};

  private _uniqueId: string = `mat-slide-toggle-${++nextUniqueId}`;
  private _slideRenderer: SlideToggleRenderer;
  private _required: boolean = false;
  private _checked: boolean = false;

  /** Reference to the focus state ripple. */
  private _focusRipple: RippleRef | null;

  /** Name value will be applied to the input element if present */
  @Input() name: string | null = null;

  /** A unique id for the slide-toggle input. If none is supplied, it will be auto-generated. */
  @Input() id: string = this._uniqueId;

  /** Whether the label should appear after or before the slide-toggle. Defaults to 'after' */
  @Input() labelPosition: 'before' | 'after' = 'after';

  /** Whether the slide-toggle element is checked or not */

  /** Used to set the aria-label attribute on the underlying input element. */
  @Input('aria-label') ariaLabel: string | null = null;

  /** Used to set the aria-labelledby attribute on the underlying input element. */
  @Input('aria-labelledby') ariaLabelledby: string | null = null;

  /** Whether the slide-toggle is required. */
  @Input()
  get required(): boolean { return this._required; }
  set required(value) { this._required = coerceBooleanProperty(value); }

  /** Whether the slide-toggle element is checked or not */
  @Input()
  get checked(): boolean { return this._checked; }
  set checked(value) {
    this._checked = coerceBooleanProperty(value);
    this._changeDetectorRef.markForCheck();
  }
  /** An event will be dispatched each time the slide-toggle changes its value. */
  @Output() change: EventEmitter<MatSlideToggleChange> = new EventEmitter<MatSlideToggleChange>();

  /** Returns the unique id for the visual hidden input. */
  get inputId(): string { return `${this.id || this._uniqueId}-input`; }

  /** Reference to the underlying input element. */
  @ViewChild('input') _inputElement: ElementRef;

  /** Reference to the ripple directive on the thumb container. */
  @ViewChild(MatRipple) _ripple: MatRipple;

  /** Ripple configuration for the mouse ripples and focus indicators. */
  _rippleConfig: RippleConfig = {centered: true, radius: 23, speedFactor: 1.5};

  constructor(elementRef: ElementRef,
              private _platform: Platform,
              private _focusMonitor: FocusMonitor,
              private _changeDetectorRef: ChangeDetectorRef,
              @Attribute('tabindex') tabIndex: string) {

    super(elementRef);
    this.tabIndex = parseInt(tabIndex) || 0;
  }

  ngAfterContentInit() {
    this._slideRenderer = new SlideToggleRenderer(this._elementRef, this._platform);

    this._focusMonitor
      .monitor(this._inputElement.nativeElement, false)
      .subscribe(focusOrigin => this._onInputFocusChange(focusOrigin));
  }

  ngOnDestroy() {
    this._focusMonitor.stopMonitoring(this._inputElement.nativeElement);
  }

  /** Method being called whenever the underlying input emits a change event. */
  _onChangeEvent(event: Event) {
    // We always have to stop propagation on the change event.
    // Otherwise the change event, from the input element, will bubble up and
    // emit its event object to the component's `change` output.
    event.stopPropagation();

    // Releasing the pointer over the `<label>` element while dragging triggers another
    // click event on the `<label>` element. This means that the checked state of the underlying
    // input changed unintentionally and needs to be changed back.
    if (this._slideRenderer.dragging) {
      this._inputElement.nativeElement.checked = this.checked;
      return;
    }

    // Sync the value from the underlying input element with the component instance.
    this.checked = this._inputElement.nativeElement.checked;

    // Emit our custom change event only if the underlying input emitted one. This ensures that
    // there is no change event, when the checked state changes programmatically.
    this._emitChangeEvent();
  }

  /** Method being called whenever the slide-toggle has been clicked. */
  _onInputClick(event: Event) {
    // We have to stop propagation for click events on the visual hidden input element.
    // By default, when a user clicks on a label element, a generated click event will be
    // dispatched on the associated input element. Since we are using a label element as our
    // root container, the click event on the `slide-toggle` will be executed twice.
    // The real click event will bubble up, and the generated click event also tries to bubble up.
    // This will lead to multiple click events.
    // Preventing bubbling for the second event will solve that issue.
    event.stopPropagation();
  }

  /** Implemented as part of ControlValueAccessor. */
  writeValue(value: any): void {
    this.checked = !!value;
  }

  /** Implemented as part of ControlValueAccessor. */
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  /** Implemented as part of ControlValueAccessor. */
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  /** Implemented as a part of ControlValueAccessor. */
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
    this._changeDetectorRef.markForCheck();
  }

  /** Focuses the slide-toggle. */
  focus() {
    this._focusMonitor.focusVia(this._inputElement.nativeElement, 'keyboard');
  }

  /** Toggles the checked state of the slide-toggle. */
  toggle() {
    this.checked = !this.checked;
  }

  /** Function is called whenever the focus changes for the input element. */
  private _onInputFocusChange(focusOrigin: FocusOrigin) {
    if (!this._focusRipple && focusOrigin === 'keyboard') {
      // For keyboard focus show a persistent ripple as focus indicator.
      this._focusRipple = this._ripple.launch(0, 0, {persistent: true, ...this._rippleConfig});
    } else if (!focusOrigin) {
      this.onTouched();

      // Fade out and clear the focus ripple if one is currently present.
      if (this._focusRipple) {
        this._focusRipple.fadeOut();
        this._focusRipple = null;
      }
    }
  }

  /**
   * Emits a change event on the `change` output. Also notifies the FormControl about the change.
   */
  private _emitChangeEvent() {
    let event = new MatSlideToggleChange();
    event.source = this;
    event.checked = this.checked;
    this.onChange(this.checked);
    this.change.emit(event);
  }

  _onDragStart() {
    if (!this.disabled) {
      this._slideRenderer.startThumbDrag(this.checked);
    }
  }

  _onDrag(event: HammerInput) {
    if (this._slideRenderer.dragging) {
      this._slideRenderer.updateThumbPosition(event.deltaX);
    }
  }

  _onDragEnd() {
    if (this._slideRenderer.dragging) {
      const newCheckedValue = this._slideRenderer.dragPercentage > 50;

      if (newCheckedValue !== this.checked) {
        this.checked = newCheckedValue;
        this._emitChangeEvent();
      }

      // The drag should be stopped outside of the current event handler, because otherwise the
      // click event will be fired before and will revert the drag change.
      setTimeout(() => this._slideRenderer.stopThumbDrag());
    }
  }

  /** Method being called whenever the label text changes. */
  _onLabelTextChange() {
    // This method is getting called whenever the label of the slide-toggle changes.
    // Since the slide-toggle uses the OnPush strategy we need to notify it about the change
    // that has been recognized by the cdkObserveContent directive.
    this._changeDetectorRef.markForCheck();
  }
}

/**
 * Renderer for the Slide Toggle component, which separates DOM modification in its own class
 */
class SlideToggleRenderer {

  /** Reference to the thumb HTMLElement. */
  private _thumbEl: HTMLElement;

  /** Reference to the thumb bar HTMLElement. */
  private _thumbBarEl: HTMLElement;

  /** Width of the thumb bar of the slide-toggle. */
  private _thumbBarWidth: number;

  /** Previous checked state before drag started. */
  private _previousChecked: boolean;

  /** Percentage of the thumb while dragging. Percentage as fraction of 100. */
  dragPercentage: number;

  /** Whether the thumb is currently being dragged. */
  dragging: boolean = false;

  constructor(elementRef: ElementRef, platform: Platform) {
    // We only need to interact with these elements when we're on the browser, so only grab
    // the reference in that case.
    if (platform.isBrowser) {
      this._thumbEl = elementRef.nativeElement.querySelector('.mat-slide-toggle-thumb-container');
      this._thumbBarEl = elementRef.nativeElement.querySelector('.mat-slide-toggle-bar');
    }
  }

  /** Initializes the drag of the slide-toggle. */
  startThumbDrag(checked: boolean) {
    if (this.dragging) { return; }

    this._thumbBarWidth = this._thumbBarEl.clientWidth - this._thumbEl.clientWidth;
    this._thumbEl.classList.add('mat-dragging');

    this._previousChecked = checked;
    this.dragging = true;
  }

  /** Resets the current drag and returns the new checked value. */
  stopThumbDrag(): boolean {
    if (!this.dragging) { return false; }

    this.dragging = false;
    this._thumbEl.classList.remove('mat-dragging');

    // Reset the transform because the component will take care of the thumb position after drag.
    applyCssTransform(this._thumbEl, '');

    return this.dragPercentage > 50;
  }

  /** Updates the thumb containers position from the specified distance. */
  updateThumbPosition(distance: number) {
    this.dragPercentage = this._getDragPercentage(distance);
    // Calculate the moved distance based on the thumb bar width.
    let dragX = (this.dragPercentage / 100) * this._thumbBarWidth;
    applyCssTransform(this._thumbEl, `translate3d(${dragX}px, 0, 0)`);
  }

  /** Retrieves the percentage of thumb from the moved distance. Percentage as fraction of 100. */
  private _getDragPercentage(distance: number) {
    let percentage = (distance / this._thumbBarWidth) * 100;

    // When the toggle was initially checked, then we have to start the drag at the end.
    if (this._previousChecked) {
      percentage += 100;
    }

    return Math.max(0, Math.min(percentage, 100));
  }

}
