/**
 * @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 {Direction} from '@angular/cdk/bidi';
import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '@angular/cdk/portal';
import {ComponentRef, EmbeddedViewRef, NgZone} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {take} from 'rxjs/operators/take';
import {Subject} from 'rxjs/Subject';
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
import {OverlayConfig} from './overlay-config';


/** An object where all of its properties cannot be written. */
export type ImmutableObject<T> = {
  readonly [P in keyof T]: T[P];
};

/**
 * Reference to an overlay that has been created with the Overlay service.
 * Used to manipulate or dispose of said overlay.
 */
export class OverlayRef implements PortalOutlet {
  private _backdropElement: HTMLElement | null = null;
  private _backdropClick: Subject<any> = new Subject();
  private _attachments = new Subject<void>();
  private _detachments = new Subject<void>();

  /** Stream of keydown events dispatched to this overlay. */
  _keydownEvents = new Subject<KeyboardEvent>();

  constructor(
      private _portalOutlet: PortalOutlet,
      private _pane: HTMLElement,
      private _config: ImmutableObject<OverlayConfig>,
      private _ngZone: NgZone,
      private _keyboardDispatcher: OverlayKeyboardDispatcher) {

    if (_config.scrollStrategy) {
      _config.scrollStrategy.attach(this);
    }
  }

  /** The overlay's HTML element */
  get overlayElement(): HTMLElement {
    return this._pane;
  }

  attach<T>(portal: ComponentPortal<T>): ComponentRef<T>;
  attach<T>(portal: TemplatePortal<T>): EmbeddedViewRef<T>;
  attach(portal: any): any;

  /**
   * Attaches content, given via a Portal, to the overlay.
   * If the overlay is configured to have a backdrop, it will be created.
   *
   * @param portal Portal instance to which to attach the overlay.
   * @returns The portal attachment result.
   */
  attach(portal: Portal<any>): any {
    let attachResult = this._portalOutlet.attach(portal);

    if (this._config.positionStrategy) {
      this._config.positionStrategy.attach(this);
    }

    // Update the pane element with the given configuration.
    this._updateStackingOrder();
    this._updateElementSize();
    this._updateElementDirection();

    if (this._config.scrollStrategy) {
      this._config.scrollStrategy.enable();
    }

    // Update the position once the zone is stable so that the overlay will be fully rendered
    // before attempting to position it, as the position may depend on the size of the rendered
    // content.
    this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => {
      this.updatePosition();
    });

    // Enable pointer events for the overlay pane element.
    this._togglePointerEvents(true);

    if (this._config.hasBackdrop) {
      this._attachBackdrop();
    }

    if (this._config.panelClass) {
      // We can't do a spread here, because IE doesn't support setting multiple classes.
      if (Array.isArray(this._config.panelClass)) {
        this._config.panelClass.forEach(cls => this._pane.classList.add(cls));
      } else {
        this._pane.classList.add(this._config.panelClass);
      }
    }

    // Only emit the `attachments` event once all other setup is done.
    this._attachments.next();

    // Track this overlay by the keyboard dispatcher
    this._keyboardDispatcher.add(this);

    return attachResult;
  }

  /**
   * Detaches an overlay from a portal.
   * @returns The portal detachment result.
   */
  detach(): any {
    if (!this.hasAttached()) {
      return;
    }

    this.detachBackdrop();

    // When the overlay is detached, the pane element should disable pointer events.
    // This is necessary because otherwise the pane element will cover the page and disable
    // pointer events therefore. Depends on the position strategy and the applied pane boundaries.
    this._togglePointerEvents(false);

    if (this._config.positionStrategy && this._config.positionStrategy.detach) {
      this._config.positionStrategy.detach();
    }

    if (this._config.scrollStrategy) {
      this._config.scrollStrategy.disable();
    }

    const detachmentResult = this._portalOutlet.detach();

    // Only emit after everything is detached.
    this._detachments.next();

    // Remove this overlay from keyboard dispatcher tracking
    this._keyboardDispatcher.remove(this);

    return detachmentResult;
  }

  /** Cleans up the overlay from the DOM. */
  dispose(): void {
    const isAttached = this.hasAttached();

    if (this._config.positionStrategy) {
      this._config.positionStrategy.dispose();
    }

    if (this._config.scrollStrategy) {
      this._config.scrollStrategy.disable();
    }

    this.detachBackdrop();
    this._keyboardDispatcher.remove(this);
    this._portalOutlet.dispose();
    this._attachments.complete();
    this._backdropClick.complete();
    this._keydownEvents.complete();

    if (isAttached) {
      this._detachments.next();
    }

    this._detachments.complete();
  }

  /** Whether the overlay has attached content. */
  hasAttached(): boolean {
    return this._portalOutlet.hasAttached();
  }

  /** Gets an observable that emits when the backdrop has been clicked. */
  backdropClick(): Observable<void> {
    return this._backdropClick.asObservable();
  }

  /** Gets an observable that emits when the overlay has been attached. */
  attachments(): Observable<void> {
    return this._attachments.asObservable();
  }

  /** Gets an observable that emits when the overlay has been detached. */
  detachments(): Observable<void> {
    return this._detachments.asObservable();
  }

  /** Gets an observable of keydown events targeted to this overlay. */
  keydownEvents(): Observable<KeyboardEvent> {
    return this._keydownEvents.asObservable();
  }

  /** Gets the the current overlay configuration, which is immutable. */
  getConfig(): OverlayConfig {
    return this._config;
  }

  /** Updates the position of the overlay based on the position strategy. */
  updatePosition() {
    if (this._config.positionStrategy) {
      this._config.positionStrategy.apply();
    }
  }

  /** Update the size properties of the overlay. */
  updateSize(sizeConfig: OverlaySizeConfig) {
    this._config = {...this._config, ...sizeConfig};
    this._updateElementSize();
  }

  /** Sets the LTR/RTL direction for the overlay. */
  setDirection(dir: Direction) {
    this._config = {...this._config, direction: dir};
    this._updateElementDirection();
  }

  /** Updates the text direction of the overlay panel. */
  private _updateElementDirection() {
    this._pane.setAttribute('dir', this._config.direction!);
  }

  /** Updates the size of the overlay element based on the overlay config. */
  private _updateElementSize() {
    if (this._config.width || this._config.width === 0) {
      this._pane.style.width = formatCssUnit(this._config.width);
    }

    if (this._config.height || this._config.height === 0) {
      this._pane.style.height = formatCssUnit(this._config.height);
    }

    if (this._config.minWidth || this._config.minWidth === 0) {
      this._pane.style.minWidth = formatCssUnit(this._config.minWidth);
    }

    if (this._config.minHeight || this._config.minHeight === 0) {
      this._pane.style.minHeight = formatCssUnit(this._config.minHeight);
    }

    if (this._config.maxWidth || this._config.maxWidth === 0) {
      this._pane.style.maxWidth = formatCssUnit(this._config.maxWidth);
    }

    if (this._config.maxHeight || this._config.maxHeight === 0) {
      this._pane.style.maxHeight = formatCssUnit(this._config.maxHeight);
    }
  }

  /** Toggles the pointer events for the overlay pane element. */
  private _togglePointerEvents(enablePointer: boolean) {
    this._pane.style.pointerEvents = enablePointer ? 'auto' : 'none';
  }

  /** Attaches a backdrop for this overlay. */
  private _attachBackdrop() {
    this._backdropElement = document.createElement('div');
    this._backdropElement.classList.add('cdk-overlay-backdrop');

    if (this._config.backdropClass) {
      this._backdropElement.classList.add(this._config.backdropClass);
    }

    // Insert the backdrop before the pane in the DOM order,
    // in order to handle stacked overlays properly.
    this._pane.parentElement!.insertBefore(this._backdropElement, this._pane);

    // Forward backdrop clicks such that the consumer of the overlay can perform whatever
    // action desired when such a click occurs (usually closing the overlay).
    this._backdropElement.addEventListener('click', () => this._backdropClick.next(null));

    // Add class to fade-in the backdrop after one frame.
    this._ngZone.runOutsideAngular(() => {
      requestAnimationFrame(() => {
        if (this._backdropElement) {
          this._backdropElement.classList.add('cdk-overlay-backdrop-showing');
        }
      });
    });
  }

  /**
   * Updates the stacking order of the element, moving it to the top if necessary.
   * This is required in cases where one overlay was detached, while another one,
   * that should be behind it, was destroyed. The next time both of them are opened,
   * the stacking will be wrong, because the detached element's pane will still be
   * in its original DOM position.
   */
  private _updateStackingOrder() {
    if (this._pane.nextSibling) {
      this._pane.parentNode!.appendChild(this._pane);
    }
  }

  /** Detaches the backdrop (if any) associated with the overlay. */
  detachBackdrop(): void {
    let backdropToDetach = this._backdropElement;

    if (backdropToDetach) {
      let finishDetach = () => {
        // It may not be attached to anything in certain cases (e.g. unit tests).
        if (backdropToDetach && backdropToDetach.parentNode) {
          backdropToDetach.parentNode.removeChild(backdropToDetach);
        }

        // It is possible that a new portal has been attached to this overlay since we started
        // removing the backdrop. If that is the case, only clear the backdrop reference if it
        // is still the same instance that we started to remove.
        if (this._backdropElement == backdropToDetach) {
          this._backdropElement = null;
        }
      };

      backdropToDetach.classList.remove('cdk-overlay-backdrop-showing');

      if (this._config.backdropClass) {
        backdropToDetach.classList.remove(this._config.backdropClass);
      }

      backdropToDetach.addEventListener('transitionend', finishDetach);

      // If the backdrop doesn't have a transition, the `transitionend` event won't fire.
      // In this case we make it unclickable and we try to remove it after a delay.
      backdropToDetach.style.pointerEvents = 'none';

      // Run this outside the Angular zone because there's nothing that Angular cares about.
      // If it were to run inside the Angular zone, every test that used Overlay would have to be
      // either async or fakeAsync.
      this._ngZone.runOutsideAngular(() => {
        setTimeout(finishDetach, 500);
      });
    }
  }
}

function formatCssUnit(value: number | string) {
  return typeof value === 'string' ? value as string : `${value}px`;
}


/** Size properties for an overlay. */
export interface OverlaySizeConfig {
  width?: number | string;
  height?: number | string;
  minWidth?: number | string;
  minHeight?: number | string;
  maxWidth?: number | string;
  maxHeight?: number | string;
}
