import { Build, Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';

import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import { Gesture, GestureDetail, IonicAnimation, MenuChangeEventDetail, MenuI, Side } from '../../interface';
import { Point, getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
import { GESTURE_CONTROLLER } from '../../utils/gesture';
import { assert, isEndSide as isEnd } from '../../utils/helpers';
import { menuController } from '../../utils/menu-controller';

@Component({
  tag: 'ion-menu',
  styleUrls: {
    ios: 'menu.ios.scss',
    md: 'menu.md.scss'
  },
  shadow: true
})
export class Menu implements ComponentInterface, MenuI {

  private animation?: any;
  private lastOnEnd = 0;
  private gesture?: Gesture;
  private blocker = GESTURE_CONTROLLER.createBlocker({ disableScroll: true });

  private mode = getIonMode(this);

  isAnimating = false;
  width!: number; // TODO
  _isOpen = false;

  backdropEl?: HTMLElement;
  menuInnerEl?: HTMLElement;
  contentEl?: HTMLElement;

  @Element() el!: HTMLIonMenuElement;

  @State() isPaneVisible = false;
  @State() isEndSide = false;

  /**
   * The content's id the menu should use.
   */
  @Prop() contentId?: string;

  /**
   * An id for the menu.
   */
  @Prop() menuId?: string;

  /**
   * The display type of the menu.
   * Available options: `"overlay"`, `"reveal"`, `"push"`.
   */
  @Prop({ mutable: true }) type?: string;

  @Watch('type')
  typeChanged(type: string, oldType: string | undefined) {
    const contentEl = this.contentEl;
    if (contentEl) {
      if (oldType !== undefined) {
        contentEl.classList.remove(`menu-content-${oldType}`);
      }
      contentEl.classList.add(`menu-content-${type}`);
      contentEl.removeAttribute('style');
    }
    if (this.menuInnerEl) {
      // Remove effects of previous animations
      this.menuInnerEl.removeAttribute('style');
    }
    this.animation = undefined;
  }

  /**
   * If `true`, the menu is disabled.
   */
  @Prop({ mutable: true }) disabled = false;

  @Watch('disabled')
  protected disabledChanged() {
    this.updateState();

    this.ionMenuChange.emit({
      disabled: this.disabled,
      open: this._isOpen
    });
  }

  /**
   * Which side of the view the menu should be placed.
   */
  @Prop({ reflectToAttr: true }) side: Side = 'start';

  @Watch('side')
  protected sideChanged() {
    this.isEndSide = isEnd(this.side);
  }

  /**
   * If `true`, swiping the menu is enabled.
   */
  @Prop() swipeGesture = true;

  @Watch('swipeGesture')
  protected swipeGestureChanged() {
    this.updateState();
  }
  /**
   * The edge threshold for dragging the menu open.
   * If a drag/swipe happens over this value, the menu is not triggered.
   */
  @Prop() maxEdgeStart = 50;

  /**
   * Emitted when the menu is about to be opened.
   */
  @Event() ionWillOpen!: EventEmitter<void>;

  /**
   * Emitted when the menu is about to be closed.
   */
  @Event() ionWillClose!: EventEmitter<void>;
  /**
   * Emitted when the menu is open.
   */
  @Event() ionDidOpen!: EventEmitter<void>;

  /**
   * Emitted when the menu is closed.
   */
  @Event() ionDidClose!: EventEmitter<void>;

  /**
   * Emitted when the menu state is changed.
   * @internal
   */
  @Event() protected ionMenuChange!: EventEmitter<MenuChangeEventDetail>;

  async connectedCallback() {
    if (this.type === undefined) {
      this.type = config.get('menuType', this.mode === 'ios' ? 'reveal' : 'overlay');
    }

    if (!Build.isBrowser) {
      this.disabled = true;
      return;
    }

    const el = this.el;
    const parent = el.parentNode as any;
    const content = this.contentId !== undefined
      ? document.getElementById(this.contentId)
      : parent && parent.querySelector && parent.querySelector('[main]');

    if (!content || !content.tagName) {
      // requires content element
      console.error('Menu: must have a "content" element to listen for drag events on.');
      return;
    }
    this.contentEl = content as HTMLElement;

    // add menu's content classes
    content.classList.add('menu-content');

    this.typeChanged(this.type!, undefined);
    this.sideChanged();

    // register this menu with the app's menu controller
    menuController._register(this);

    this.gesture = (await import('../../utils/gesture')).createGesture({
      el: document,
      gestureName: 'menu-swipe',
      gesturePriority: 30,
      threshold: 10,
      canStart: ev => this.canStart(ev),
      onWillStart: () => this.onWillStart(),
      onStart: () => this.onStart(),
      onMove: ev => this.onMove(ev),
      onEnd: ev => this.onEnd(ev),
    });
    this.updateState();
  }

  async componentDidLoad() {
    this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen });
    this.updateState();
  }

  disconnectedCallback() {
    this.blocker.destroy();
    menuController._unregister(this);
    if (this.animation) {
      this.animation.destroy();
    }
    if (this.gesture) {
      this.gesture.destroy();
      this.gesture = undefined;
    }

    this.animation = undefined;
    this.contentEl = this.backdropEl = this.menuInnerEl = undefined;
  }

  @Listen('ionSplitPaneVisible', { target: 'body' })
  onSplitPaneChanged(ev: CustomEvent) {
    this.isPaneVisible = ev.detail.isPane(this.el);
    this.updateState();
  }

  @Listen('click', { capture: true })
  onBackdropClick(ev: any) {
    if (this._isOpen && this.lastOnEnd < ev.timeStamp - 100) {
      const shouldClose = (ev.composedPath)
        ? !ev.composedPath().includes(this.menuInnerEl)
        : false;

      if (shouldClose) {
        ev.preventDefault();
        ev.stopPropagation();
        this.close();
      }
    }
  }

  /**
   * Returns `true` is the menu is open.
   */
  @Method()
  isOpen(): Promise<boolean> {
    return Promise.resolve(this._isOpen);
  }

  /**
   * Returns `true` is the menu is active.
   *
   * A menu is active when it can be opened or closed, meaning it's enabled
   * and it's not part of a `ion-split-pane`.
   */
  @Method()
  isActive(): Promise<boolean> {
    return Promise.resolve(this._isActive());
  }

  /**
   * Opens the menu. If the menu is already open or it can't be opened,
   * it returns `false`.
   */
  @Method()
  open(animated = true): Promise<boolean> {
    return this.setOpen(true, animated);
  }

  /**
   * Closes the menu. If the menu is already closed or it can't be closed,
   * it returns `false`.
   */
  @Method()
  close(animated = true): Promise<boolean> {
    return this.setOpen(false, animated);
  }

  /**
   * Toggles the menu. If the menu is already open, it will try to close, otherwise it will try to open it.
   * If the operation can't be completed successfully, it returns `false`.
   */
  @Method()
  toggle(animated = true): Promise<boolean> {
    return this.setOpen(!this._isOpen, animated);
  }

  /**
   * Opens or closes the button.
   * If the operation can't be completed successfully, it returns `false`.
   */
  @Method()
  setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
    return menuController._setOpen(this, shouldOpen, animated);
  }

  async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
    // If the menu is disabled or it is currently being animated, let's do nothing
    if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) {
      return false;
    }

    this.beforeAnimation(shouldOpen);
    await this.loadAnimation();
    await this.startAnimation(shouldOpen, animated);
    this.afterAnimation(shouldOpen);

    return true;
  }

  private async loadAnimation(): Promise<void> {
    // Menu swipe animation takes the menu's inner width as parameter,
    // If `offsetWidth` changes, we need to create a new animation.
    const width = this.menuInnerEl!.offsetWidth;
    if (width === this.width && this.animation !== undefined) {
      return;
    }
    this.width = width;

    // Destroy existing animation
    if (this.animation) {
      this.animation.destroy();
      this.animation = undefined;
    }
    // Create new animation
    this.animation = await menuController._createAnimation(this.type!, this);
    if (!config.getBoolean('animated', true)) {
      this.animation.duration(0);
    }
    this.animation.fill('both');
  }

  private async startAnimation(shouldOpen: boolean, animated: boolean): Promise<void> {
    const isReversed = !shouldOpen;
    const ani = (this.animation as IonicAnimation)!
      .direction((isReversed) ? 'reverse' : 'normal')
      .easing((isReversed) ? 'cubic-bezier(0.4, 0.0, 0.6, 1)' : 'cubic-bezier(0.0, 0.0, 0.2, 1)');

    if (animated) {
      await ani.playAsync();
    } else {
      ani.playSync();
    }
  }

  private _isActive() {
    return !this.disabled && !this.isPaneVisible;
  }

  private canSwipe(): boolean {
    return this.swipeGesture && !this.isAnimating && this._isActive();
  }

  private canStart(detail: GestureDetail): boolean {
    if (!this.canSwipe()) {
      return false;
    }
    if (this._isOpen) {
      return true;
    // TODO error
    } else if (menuController._getOpenSync()) {
      return false;
    }
    return checkEdgeSide(
      window,
      detail.currentX,
      this.isEndSide,
      this.maxEdgeStart
    );
  }

  private onWillStart(): Promise<void> {
    this.beforeAnimation(!this._isOpen);
    return this.loadAnimation();
  }

  private onStart() {
    if (!this.isAnimating || !this.animation) {
      assert(false, 'isAnimating has to be true');
      return;
    }

    // the cloned animation should not use an easing curve during seek
    (this.animation as IonicAnimation)
      .direction((this._isOpen) ? 'reverse' : 'normal')
      .progressStart(true);
  }

  private onMove(detail: GestureDetail) {
    if (!this.isAnimating || !this.animation) {
      assert(false, 'isAnimating has to be true');
      return;
    }

    const delta = computeDelta(detail.deltaX, this._isOpen, this.isEndSide);
    const stepValue = delta / this.width;

    this.animation.progressStep(stepValue);
  }

  private onEnd(detail: GestureDetail) {
    if (!this.isAnimating || !this.animation) {
      assert(false, 'isAnimating has to be true');
      return;
    }
    const isOpen = this._isOpen;
    const isEndSide = this.isEndSide;
    const delta = computeDelta(detail.deltaX, isOpen, isEndSide);
    const width = this.width;
    const stepValue = delta / width;
    const velocity = detail.velocityX;
    const z = width / 2.0;
    const shouldCompleteRight =
      velocity >= 0 && (velocity > 0.2 || detail.deltaX > z);

    const shouldCompleteLeft =
      velocity <= 0 && (velocity < -0.2 || detail.deltaX < -z);

    const shouldComplete = isOpen
      ? isEndSide ? shouldCompleteRight : shouldCompleteLeft
      : isEndSide ? shouldCompleteLeft : shouldCompleteRight;

    let shouldOpen = !isOpen && shouldComplete;
    if (isOpen && !shouldComplete) {
      shouldOpen = true;
    }

    this.lastOnEnd = detail.timeStamp;

    // Account for rounding errors in JS
    let newStepValue = (shouldComplete) ? 0.001 : -0.001;

    /**
     * TODO: stepValue can sometimes return a negative
     * value, but you can't have a negative time value
     * for the cubic bezier curve (at least with web animations)
     * Not sure if the negative step value is an error or not
     */
    const adjustedStepValue = (stepValue <= 0) ? 0.01 : stepValue;

    /**
     * Animation will be reversed here, so need to
     * reverse the easing curve as well
     *
     * Additionally, we need to account for the time relative
     * to the new easing curve, as `stepValue` is going to be given
     * in terms of a linear curve.
     */
    newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(0.4, 0), new Point(0.6, 1), new Point(1, 1), adjustedStepValue);

    this.animation
      .easing('cubic-bezier(0.4, 0.0, 0.6, 1)')
      .onFinish(
        () => this.afterAnimation(shouldOpen),
        { oneTimeCallback: true })
      .progressEnd(shouldComplete ? 1 : 0, newStepValue, 300);
  }

  private beforeAnimation(shouldOpen: boolean) {
    assert(!this.isAnimating, '_before() should not be called while animating');

    // this places the menu into the correct location before it animates in
    // this css class doesn't actually kick off any animations
    this.el.classList.add(SHOW_MENU);
    if (this.backdropEl) {
      this.backdropEl.classList.add(SHOW_BACKDROP);
    }
    this.blocker.block();
    this.isAnimating = true;
    if (shouldOpen) {
      this.ionWillOpen.emit();
    } else {
      this.ionWillClose.emit();
    }
  }

  private afterAnimation(isOpen: boolean) {
    assert(this.isAnimating, '_before() should be called while animating');

    // keep opening/closing the menu disabled for a touch more yet
    // only add listeners/css if it's enabled and isOpen
    // and only remove listeners/css if it's not open
    // emit opened/closed events
    this._isOpen = isOpen;
    this.isAnimating = false;
    if (!this._isOpen) {
      this.blocker.unblock();
    }

    if (isOpen) {
      // add css class
      if (this.contentEl) {
        this.contentEl.classList.add(MENU_CONTENT_OPEN);
      }

      // emit open event
      this.ionDidOpen.emit();
    } else {
      // remove css classes
      this.el.classList.remove(SHOW_MENU);
      if (this.contentEl) {
        this.contentEl.classList.remove(MENU_CONTENT_OPEN);
      }
      if (this.backdropEl) {
        this.backdropEl.classList.remove(SHOW_BACKDROP);
      }

      if (this.animation) {
        this.animation.stop();
      }

      // emit close event
      this.ionDidClose.emit();
    }
  }

  private updateState() {
    const isActive = this._isActive();
    if (this.gesture) {
      this.gesture.setDisabled(!isActive || !this.swipeGesture);
    }

    // Close menu immediately
    if (!isActive && this._isOpen) {
      // close if this menu is open, and should not be enabled
      this.forceClosing();
    }

    if (!this.disabled) {
      menuController._setActiveMenu(this);
    }
    assert(!this.isAnimating, 'can not be animating');
  }

  private forceClosing() {
    assert(this._isOpen, 'menu cannot be closed');

    this.isAnimating = true;
    const ani = (this.animation as IonicAnimation)!.direction('reverse');
    ani.playSync();
    this.afterAnimation(false);
  }

  render() {
    const { isEndSide, type, disabled, mode, isPaneVisible } = this;

    return (
      <Host
        role="navigation"
        class={{
          [mode]: true,
          [`menu-type-${type}`]: true,
          'menu-enabled': !disabled,
          'menu-side-end': isEndSide,
          'menu-side-start': !isEndSide,
          'menu-pane-visible': isPaneVisible
        }}
      >
        <div
          class="menu-inner"
          ref={el => this.menuInnerEl = el}
        >
          <slot></slot>
        </div>

        <ion-backdrop
          ref={el => this.backdropEl = el}
          class="menu-backdrop"
          tappable={false}
          stopPropagation={false}
        />
      </Host>
    );
  }
}

const computeDelta = (
  deltaX: number,
  isOpen: boolean,
  isEndSide: boolean
): number => {
  return Math.max(0, isOpen !== isEndSide ? -deltaX : deltaX);
};

const checkEdgeSide = (
  win: Window,
  posX: number,
  isEndSide: boolean,
  maxEdgeStart: number
): boolean => {
  if (isEndSide) {
    return posX >= win.innerWidth - maxEdgeStart;
  } else {
    return posX <= maxEdgeStart;
  }
};

const SHOW_MENU = 'show-menu';
const SHOW_BACKDROP = 'show-backdrop';
const MENU_CONTENT_OPEN = 'menu-content-open';
