/**
 * @license
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../../styles/shared-styles';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {Debouncer} from '@polymer/polymer/lib/utils/debounce';
import {timeOut} from '@polymer/polymer/lib/utils/async';
import {getRootElement} from '../../../scripts/rootElement';
import {Constructor} from '../../../utils/common-util';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
import {property, observe} from '@polymer/decorators';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {
  pushScrollLock,
  removeScrollLock,
} from '@polymer/iron-overlay-behavior/iron-scroll-manager';

interface ShowAlertEventDetail {
  message: string;
  dismissOnNavigation?: boolean;
}

interface ReloadEventDetail {
  clearPatchset?: boolean;
}

const HOVER_CLASS = 'hovered';
const HIDE_CLASS = 'hide';

/**
 * How long should we wait before showing the hovercard when the user hovers
 * over the element?
 */
const SHOW_DELAY_MS = 550;

/**
 * How long should we wait before hiding the hovercard when the user moves from
 * target to the hovercard.
 *
 * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
 */
const HIDE_DELAY_MS = 500;

/**
 * The mixin for gr-hovercard-behavior.
 *
 * @example
 *
 * // LegacyElementMixin is still needed to support the old lifecycles
 * // TODO: Replace old life cycles with new ones.
 *
 * class YourComponent extends hovercardBehaviorMixin(
 *  LegacyElementMixin(PolymerElement)
 *
 * @see gr-hovercard.ts
 *
 * // following annotations are required for polylint
 * @polymer
 * @mixinFunction
 */
export const hovercardBehaviorMixin = dedupingMixin(
  <T extends Constructor<PolymerElement & LegacyElementMixin>>(
    superClass: T
  ): T & Constructor<GrHovercardBehaviorInterface> => {
    /**
     * @polymer
     * @mixinClass
     */
    class Mixin extends superClass {
      @property({type: Object})
      _target: Element | null = null;

      // Determines whether or not the hovercard is visible.
      @property({type: Boolean})
      _isShowing = false;

      // The `id` of the element that the hovercard is anchored to.
      @property({type: String})
      for?: string;

      /**
       * The spacing between the top of the hovercard and the element it is
       * anchored to.
       */
      @property({type: Number})
      offset = 14;

      /**
       * Positions the hovercard to the top, right, bottom, left, bottom-left,
       * bottom-right, top-left, or top-right of its content.
       */
      @property({type: String})
      position = 'right';

      @property({type: Object})
      container: HTMLElement | null = null;

      /**
       * ID for the container element.
       */
      @property({type: String})
      containerId = 'gr-hovercard-container';

      private _hideDebouncer: Debouncer | null = null;

      private _showDebouncer: Debouncer | null = null;

      private _isScheduledToShow?: boolean;

      private _isScheduledToHide?: boolean;

      /** @override */
      attached() {
        super.attached();
        if (!this._target) {
          this._target = this.target;
        }
        this.listen(this._target, 'mouseenter', 'debounceShow');
        this.listen(this._target, 'focus', 'debounceShow');
        this.listen(this._target, 'mouseleave', 'debounceHide');
        this.listen(this._target, 'blur', 'debounceHide');

        // when click, dismiss immediately
        this.listen(this._target, 'click', 'hide');

        // show the hovercard if mouse moves to hovercard
        // this will cancel pending hide as well
        this.listen(this, 'mouseenter', 'show');
        this.listen(this, 'mouseenter', 'lock');
        // when leave hovercard, hide it immediately
        this.listen(this, 'mouseleave', 'hide');
        this.listen(this, 'mouseleave', 'unlock');
      }

      detached() {
        super.detached();
        this.unlock();
      }

      /** @override */
      ready() {
        super.ready();
        // First, check to see if the container has already been created.
        this.container = getRootElement().querySelector('#' + this.containerId);

        if (this.container) {
          return;
        }

        // If it does not exist, create and initialize the hovercard container.
        this.container = document.createElement('div');
        this.container.setAttribute('id', this.containerId);
        getRootElement().appendChild(this.container);
      }

      removeListeners() {
        this.unlisten(this._target, 'mouseenter', 'debounceShow');
        this.unlisten(this._target, 'focus', 'debounceShow');
        this.unlisten(this._target, 'mouseleave', 'debounceHide');
        this.unlisten(this._target, 'blur', 'debounceHide');
        this.unlisten(this._target, 'click', 'hide');
      }

      debounceHide() {
        this.cancelShowDebouncer();
        if (!this._isShowing || this._isScheduledToHide) return;
        this._isScheduledToHide = true;
        this._hideDebouncer = Debouncer.debounce(
          this._hideDebouncer,
          timeOut.after(HIDE_DELAY_MS),
          () => {
            // This happens when hide immediately through click or mouse leave
            // on the hovercard
            if (!this._isScheduledToHide) return;
            this.hide();
          }
        );
      }

      cancelHideDebouncer() {
        if (this._hideDebouncer) {
          this._hideDebouncer.cancel();
          this._isScheduledToHide = false;
        }
      }

      /**
       * Hovercard elements are created outside of <gr-app>, so if you want to fire
       * events, then you probably want to do that through the target element.
       */

      dispatchEventThroughTarget(eventName: string): void;

      dispatchEventThroughTarget(
        eventName: 'show-alert',
        detail: ShowAlertEventDetail
      ): void;

      dispatchEventThroughTarget(
        eventName: 'reload',
        detail: ReloadEventDetail
      ): void;

      dispatchEventThroughTarget(eventName: string, detail?: unknown) {
        if (!detail) detail = {};
        if (this._target)
          this._target.dispatchEvent(
            new CustomEvent(eventName, {
              detail,
              bubbles: true,
              composed: true,
            })
          );
      }

      /**
       * Returns the target element that the hovercard is anchored to (the `id` of
       * the `for` property).
       */
      get target(): Element {
        const parentNode = this.parentNode;
        // If the parentNode is a document fragment, then we need to use the host.
        const ownerRoot = this.getRootNode() as ShadowRoot;
        let target;
        if (this.for) {
          target = ownerRoot.querySelector('#' + this.for);
        } else {
          target =
            !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
              ? ownerRoot.host
              : parentNode;
        }
        return target as Element;
      }

      /**
       * unlock scroll, this will resume the scroll outside of the hovercard.
       */
      unlock() {
        removeScrollLock(this);
      }

      /**
       * Hides/closes the hovercard. This occurs when the user triggers the
       * `mouseleave` event on the hovercard's `target` element (as long as the
       * user is not hovering over the hovercard).
       *
       */
      hide(e?: MouseEvent) {
        this.cancelHideDebouncer();
        this.cancelShowDebouncer();
        if (!this._isShowing) {
          return;
        }

        // If the user is now hovering over the hovercard or the user is returning
        // from the hovercard but now hovering over the target (to stop an annoying
        // flicker effect), just return.
        if (e) {
          if (
            e.relatedTarget === this ||
            (e.target === this && e.relatedTarget === this._target)
          ) {
            return;
          }
        }

        // Mark that the hovercard is not visible and do not allow focusing
        this._isShowing = false;

        // Clear styles in preparation for the next time we need to show the card
        this.classList.remove(HOVER_CLASS);

        // Reset and remove the hovercard from the DOM
        this.style.cssText = '';
        this.$['container'].setAttribute('tabindex', '-1');

        // Remove the hovercard from the container, given that it is still a child
        // of the container.
        if (this.container?.contains(this)) {
          this.container.removeChild(this);
        }
      }

      /**
       * Shows/opens the hovercard with a fixed delay.
       */
      debounceShow() {
        this.debounceShowBy(SHOW_DELAY_MS);
      }

      /**
       * Shows/opens the hovercard with the given delay.
       */
      debounceShowBy(delayMs: number) {
        this.cancelHideDebouncer();
        if (this._isShowing || this._isScheduledToShow) return;
        this._isScheduledToShow = true;
        this._showDebouncer = Debouncer.debounce(
          this._showDebouncer,
          timeOut.after(delayMs),
          () => {
            // This happens when the mouse leaves the target before the delay is over.
            if (!this._isScheduledToShow) return;
            this.show();
          }
        );
      }

      cancelShowDebouncer() {
        if (this._showDebouncer) {
          this._showDebouncer.cancel();
          this._isScheduledToShow = false;
        }
      }

      /**
       * Lock background scroll but enable scroll inside of current hovercard.
       */
      lock() {
        pushScrollLock(this);
      }

      /**
       * Shows/opens the hovercard. This occurs when the user triggers the
       * `mousenter` event on the hovercard's `target` element.
       */
      show() {
        this.cancelHideDebouncer();
        this.cancelShowDebouncer();
        if (this._isShowing || !this.container) {
          return;
        }

        // Mark that the hovercard is now visible
        this._isShowing = true;
        this.setAttribute('tabindex', '0');

        // Add it to the DOM and calculate its position
        this.container.appendChild(this);
        // We temporarily hide the hovercard until we have found the correct
        // position for it.
        this.classList.add(HIDE_CLASS);
        this.classList.add(HOVER_CLASS);
        // Make sure that the hovercard actually rendered and all dom-if
        // statements processed, so that we can measure the (invisible)
        // hovercard properly in updatePosition().
        flush();
        this.updatePosition();
        this.classList.remove(HIDE_CLASS);
      }

      updatePosition() {
        const positionsToTry = new Set([
          this.position,
          'right',
          'bottom-right',
          'top-right',
          'bottom',
          'top',
          'bottom-left',
          'top-left',
          'left',
        ]);
        for (const position of positionsToTry) {
          this.updatePositionTo(position);
          if (this._isInsideViewport()) return;
        }
        console.warn('Could not find a visible position for the hovercard.');
      }

      _isInsideViewport() {
        const thisRect = this.getBoundingClientRect();
        if (thisRect.top < 0) return false;
        if (thisRect.left < 0) return false;
        const docuRect = document.documentElement.getBoundingClientRect();
        if (thisRect.bottom > docuRect.height) return false;
        if (thisRect.right > docuRect.width) return false;
        return true;
      }

      /**
       * Updates the hovercard's position based the current position of the `target`
       * element.
       *
       * The hovercard is supposed to stay open if the user hovers over it.
       * To keep it open when the user moves away from the target, the bounding
       * rects of the target and hovercard must touch or overlap.
       *
       * NOTE: You do not need to directly call this method unless you need to
       * update the position of the tooltip while it is already visible (the
       * target element has moved and the tooltip is still open).
       */
      updatePositionTo(position: string) {
        if (!this._target) {
          return;
        }

        // Make sure that thisRect will not get any paddings and such included
        // in the width and height of the bounding client rect.
        this.style.cssText = '';

        const docuRect = document.documentElement.getBoundingClientRect();
        const targetRect = this._target.getBoundingClientRect();
        const thisRect = this.getBoundingClientRect();

        const targetLeft = targetRect.left - docuRect.left;
        const targetTop = targetRect.top - docuRect.top;

        let hovercardLeft;
        let hovercardTop;

        switch (position) {
          case 'top':
            hovercardLeft =
              targetLeft + (targetRect.width - thisRect.width) / 2;
            hovercardTop = targetTop - thisRect.height - this.offset;
            break;
          case 'bottom':
            hovercardLeft =
              targetLeft + (targetRect.width - thisRect.width) / 2;
            hovercardTop = targetTop + targetRect.height + this.offset;
            break;
          case 'left':
            hovercardLeft = targetLeft - thisRect.width - this.offset;
            hovercardTop =
              targetTop + (targetRect.height - thisRect.height) / 2;
            break;
          case 'right':
            hovercardLeft = targetLeft + targetRect.width + this.offset;
            hovercardTop =
              targetTop + (targetRect.height - thisRect.height) / 2;
            break;
          case 'bottom-right':
            hovercardLeft = targetLeft + targetRect.width + this.offset;
            hovercardTop = targetTop;
            break;
          case 'bottom-left':
            hovercardLeft = targetLeft - thisRect.width - this.offset;
            hovercardTop = targetTop;
            break;
          case 'top-left':
            hovercardLeft = targetLeft - thisRect.width - this.offset;
            hovercardTop = targetTop + targetRect.height - thisRect.height;
            break;
          case 'top-right':
            hovercardLeft = targetLeft + targetRect.width + this.offset;
            hovercardTop = targetTop + targetRect.height - thisRect.height;
            break;
        }

        this.style.left = `${hovercardLeft}px`;
        this.style.top = `${hovercardTop}px`;
      }

      /**
       * Responds to a change in the `for` value and gets the updated `target`
       * element for the hovercard.
       */
      @observe('for')
      _forChanged() {
        this._target = this.target;
      }
    }

    return Mixin;
  }
);

export interface GrHovercardBehaviorInterface {
  attached(): void;
  ready(): void;
  removeListeners(): void;
  debounceHide(): void;
  cancelHideDebouncer(): void;
  dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
  hide(e?: MouseEvent): void;
  debounceShow(): void;
  debounceShowBy(delayMs: number): void;
  cancelShowDebouncer(): void;
  show(): void;
  updatePosition(): void;
  updatePositionTo(position: string): void;
}
