import { createId } from "../utils/id";
import type { ObjectDirective } from "vue";

/**
 * @package admin
 */
type Placements = "top" | "right" | "bottom" | "left";

const availableTooltipPlacements: Placements[] = ["top", "right", "bottom", "left"];

const tooltipRegistry = new Map<string, Tooltip>();

/**
 * @private
 */
class Tooltip {
  private _id?: string;

  private _placement?: Placements;

  private _message: string;

  private _width: number | string;

  private _parentDOMElement: HTMLElement;

  private _showDelay: number;

  private _hideDelay: number;

  private _disabled: boolean;

  private _appearance: string;

  private _showOnDisabledElements: boolean;

  private _zIndex: number | null;

  private _isShown: boolean;

  private _state: boolean;

  private _DOMElement: HTMLElement | null;

  private _parentDOMElementWrapper: HTMLElement | null;

  private _actualTooltipPlacement: Placements | null;

  private _timeout?: ReturnType<typeof setTimeout>;

  constructor({
    id = createId(),
    placement = "top",
    message,
    width = 200,
    element,
    showDelay = 100,
    hideDelay = showDelay,
    disabled = false,
    appearance = "dark",
    showOnDisabledElements = false,
    zIndex = null,
  }: {
    id?: string;
    placement?: Placements;
    message?: string;
    width?: number | string;
    element: HTMLElement;
    showDelay?: number;
    hideDelay?: number;
    disabled: boolean;
    appearance?: string;
    showOnDisabledElements?: boolean;
    zIndex?: number | null;
  }) {
    this._id = id;
    this._placement = Tooltip.validatePlacement(placement);
    this._message = Tooltip.validateMessage(message);
    this._width = Tooltip.validateWidth(width);
    this._parentDOMElement = element;
    this._showDelay = showDelay ?? 100;
    this._hideDelay = hideDelay ?? 100;
    this._disabled = disabled;
    this._appearance = appearance;
    this._showOnDisabledElements = showOnDisabledElements;
    this._zIndex = zIndex;

    // initialize tooltip variables
    this._isShown = false;
    this._state = false;
    this._DOMElement = null;
    this._parentDOMElementWrapper = null;
    this._actualTooltipPlacement = null;
  }

  get id() {
    return this._id;
  }

  /**
   * Initializes the tooltip.
   * Needs to be called after the parent DOM Element is inserted to the DOM.
   */
  init() {
    this._DOMElement = this.createDOMElement();

    if (this._showOnDisabledElements) {
      this._parentDOMElementWrapper = this.createParentDOMElementWrapper();
    }

    this.registerEvents();
  }

  /**
   * Updates the styles and/or text of the tooltip
   */
  update({
    message,
    placement,
    width,
    showDelay,
    hideDelay,
    disabled,
    appearance,
    showOnDisabledElements,
    zIndex,
  }: {
    message?: string;
    placement?: Placements;
    width?: number | string;
    showDelay?: number;
    hideDelay?: number;
    disabled?: boolean;
    appearance?: string;
    showOnDisabledElements?: boolean;
    zIndex?: number | null;
  }) {
    if (message && this._message !== message) {
      this._message = Tooltip.validateMessage(message);

      if (this._DOMElement) {
        this._DOMElement.innerHTML = this._message;
      }

      this.registerEvents();
    }

    if (width && this._width !== width) {
      this._width = Tooltip.validateWidth(width);
      this._DOMElement!.style.width = `${this._width}px`;
    }

    if (placement && this._placement !== placement) {
      this._placement = Tooltip.validatePlacement(placement);
      this._placeTooltip();
    }

    if (showDelay && this._showDelay !== showDelay) {
      this._showDelay = showDelay;
    }

    if (hideDelay && this._hideDelay !== hideDelay) {
      this._hideDelay = hideDelay;
    }

    if (disabled !== undefined && this._disabled !== disabled) {
      this._disabled = disabled;
    }

    if (appearance && this._appearance !== appearance) {
      this._DOMElement!.classList.remove(`mt-tooltip--${this._appearance}`);
      this._appearance = appearance;
      this._DOMElement!.classList.add(`mt-tooltip--${this._appearance}`);
    }

    if (
      showOnDisabledElements !== undefined &&
      this._showOnDisabledElements !== showOnDisabledElements
    ) {
      this._showOnDisabledElements = showOnDisabledElements;
    }

    if (zIndex !== this._zIndex && zIndex !== undefined) {
      this._zIndex = zIndex;
    }
  }

  /**
   * Creates a wrapper around the original DOMElement.
   * This is needed because a disabled input field does not fire any mouse events and prevents the tooltip
   * therefore from working.
   * @returns {HTMLElement}
   */
  createParentDOMElementWrapper() {
    const element = document.createElement("div");
    element.classList.add("mt-tooltip--wrapper");

    this._parentDOMElement.parentNode!.insertBefore(element, this._parentDOMElement);
    element.appendChild(this._parentDOMElement);

    return element;
  }

  createDOMElement(): HTMLElement {
    const element = document.createElement("div");
    element.innerHTML = this._message;
    element.style.width = `${this._width}px`;
    element.setAttribute("aria-hidden", "false");
    element.setAttribute("aria-role", "tooltip");
    element.setAttribute("aria-label", "currently-opened-tooltip");
    element.classList.add("mt-tooltip");
    element.classList.add(`mt-tooltip--${this._appearance}`);

    if (this._zIndex !== null) {
      element.style.zIndex = this._zIndex.toFixed(0);
    }

    return element;
  }

  registerEvents() {
    if (this._parentDOMElementWrapper) {
      this._parentDOMElementWrapper.addEventListener("mouseenter", this.onMouseToggle.bind(this));
      this._parentDOMElementWrapper.addEventListener("mouseleave", this.onMouseToggle.bind(this));
    } else {
      this._parentDOMElement.addEventListener("mouseenter", this.onMouseToggle.bind(this));
      this._parentDOMElement.addEventListener("mouseleave", this.onMouseToggle.bind(this));
    }
    this._DOMElement!.addEventListener("mouseenter", this.onMouseToggle.bind(this));
    this._DOMElement!.addEventListener("mouseleave", this.onMouseToggle.bind(this));
  }

  /**
   * Sets the state and triggers the toggle.
   */
  onMouseToggle(event: MouseEvent) {
    this._state = event.type === "mouseenter";

    if (this._timeout) {
      clearTimeout(this._timeout);
    }

    this._timeout = setTimeout(
      this._toggle.bind(this),
      this._state ? this._showDelay : this._hideDelay,
    );
  }

  _toggle() {
    if (this._state && !this._isShown && this._doesParentExist()) {
      this.showTooltip();
      return;
    }

    if (!this._state && this._isShown) {
      this.hideTooltip();
    }
  }

  /**
   * Gets the parent element by tag name and tooltip id and returns true or false whether the element exists.
   * @returns {boolean}
   * @private
   */
  _doesParentExist() {
    const tooltipIdOfParentElement = this._parentDOMElement.getAttribute("tooltip-id") ?? "";
    const htmlTagOfParentElement = this._parentDOMElement.tagName.toLowerCase();

    return !!document.querySelector(
      `${htmlTagOfParentElement}[tooltip-id="${tooltipIdOfParentElement}"]`,
    );
  }

  /**
   * Appends the tooltip to the DOM and sets a suitable position
   */
  showTooltip() {
    if (this._disabled) {
      return;
    }
    document.body.appendChild(this._DOMElement!);

    this._placeTooltip();
    this._isShown = true;
  }

  /**
   * Removes the tooltip from the DOM
   */
  hideTooltip() {
    if (this._disabled) {
      return;
    }

    this._DOMElement!.remove();
    this._isShown = false;
  }

  _placeTooltip() {
    let possiblePlacements = availableTooltipPlacements;
    let placement = this._placement;
    possiblePlacements = possiblePlacements.filter((pos) => pos !== placement);

    // Remove previous placement class if it exists
    this._DOMElement!.classList.remove(`mt-tooltip--${this._actualTooltipPlacement!}`);

    // Set the tooltip to the desired place
    this._setDOMElementPosition(this._calculateTooltipPosition(placement ?? "top"));
    this._actualTooltipPlacement = placement ?? null;

    // Check if the tooltip is fully visible in viewport and change position if not
    while (!this._isElementInViewport(this._DOMElement!)) {
      // The tooltip wont fit in any position
      if (possiblePlacements.length < 1) {
        this._actualTooltipPlacement = this._placement ?? null;
        this._setDOMElementPosition(this._calculateTooltipPosition(this._placement ?? "top"));
        break;
      }
      // try the next position in the possiblePositions array
      placement = possiblePlacements.shift();
      this._setDOMElementPosition(this._calculateTooltipPosition(placement ?? "top"));
      this._actualTooltipPlacement = placement ?? null;
    }

    this._DOMElement!.classList.add(`mt-tooltip--${this._actualTooltipPlacement ?? ""}`);
  }

  _setDOMElementPosition({ top, left }: { top: string; left: string }) {
    this._DOMElement!.style.position = "fixed";
    this._DOMElement!.style.top = top;
    this._DOMElement!.style.left = left;
  }

  _calculateTooltipPosition(placement: Placements) {
    const boundingBox = this._parentDOMElement.getBoundingClientRect();
    const secureOffset = 10;

    let top;
    let left;

    switch (placement) {
      case "bottom":
        top = `${boundingBox.top + boundingBox.height + secureOffset}px`;
        left = `${boundingBox.left + boundingBox.width / 2 - this._DOMElement!.offsetWidth / 2}px`;
        break;
      case "left":
        top = `${boundingBox.top + boundingBox.height / 2 - this._DOMElement!.offsetHeight / 2}px`;
        left = `${boundingBox.left - secureOffset - this._DOMElement!.offsetWidth}px`;
        break;
      case "right":
        top = `${boundingBox.top + boundingBox.height / 2 - this._DOMElement!.offsetHeight / 2}px`;
        left = `${boundingBox.right + secureOffset}px`;
        break;
      case "top":
      default:
        top = `${boundingBox.top - this._DOMElement!.offsetHeight - secureOffset}px`;
        left = `${boundingBox.left + boundingBox.width / 2 - this._DOMElement!.offsetWidth / 2}px`;
    }
    return { top: top, left: left };
  }

  _isElementInViewport(element: HTMLElement) {
    // get position
    const boundingClientRect = element.getBoundingClientRect();
    const windowHeight = window.innerHeight || document.documentElement.clientHeight;
    const windowWidth = window.innerWidth || document.documentElement.clientWidth;

    // calculate which borders are in viewport
    const visibleBorders = {
      top: boundingClientRect.top > 0,
      right: boundingClientRect.right < windowWidth,
      bottom: boundingClientRect.bottom < windowHeight,
      left: boundingClientRect.left > 0,
    };

    return (
      visibleBorders.top && visibleBorders.right && visibleBorders.bottom && visibleBorders.left
    );
  }

  static validatePlacement<P extends Placements>(placement: P): Placements {
    if (!availableTooltipPlacements.includes(placement)) {
      console.warn(
        "Tooltip Directive",
        `The modifier has to be one of these "${availableTooltipPlacements.join(",")}"`,
      );

      return "top";
    }
    return placement;
  }

  static validateMessage(message?: string): string {
    if (typeof message !== "string") {
      console.warn("Tooltip Directive", "The tooltip needs a message with type string");
    }

    return message ?? "";
  }

  static validateWidth(width: number | string): number | string {
    if (width === "auto") {
      return width;
    }

    if (typeof width !== "number" || width < 1) {
      console.warn("Tooltip Directive", "The tooltip width has to be a number greater 0");
      return 200;
    }

    return width;
  }

  static validateDelay(delay: number): number {
    if (typeof delay !== "number" || delay < 1) {
      console.warn("Tooltip Directive", "The tooltip delay has to be a number greater 0");
      return 100;
    }

    return delay;
  }
}

/**
 * Helper function for creating or updating a tooltip instance
 */
function createOrUpdateTooltip(
  el: HTMLElement,
  {
    value,
    modifiers,
  }: {
    value: {
      message: string;
      position: Placements;
      showDelay: number;
      hideDelay: number;
      disabled: boolean;
      appearance: string;
      width: number | string;
      showOnDisabledElements: boolean;
      zIndex: number;
    };
    modifiers: {
      [key: string]: unknown;
    };
  },
) {
  let message: string = typeof value === "string" ? value : value.message;
  message = message ? message.trim() : "";

  const placement = value.position || Object.keys(modifiers)[0];
  const showDelay = value.showDelay;
  const hideDelay = value.hideDelay;
  const disabled = value.disabled;
  const appearance = value.appearance;
  const width = value.width;
  const showOnDisabledElements = value.showOnDisabledElements;
  const zIndex = value.zIndex;

  const configuration = {
    element: el,
    message: message,
    placement: placement,
    width: width,
    showDelay: showDelay,
    hideDelay: hideDelay,
    disabled: disabled,
    appearance: appearance,
    showOnDisabledElements: showOnDisabledElements,
    zIndex: zIndex,
  };

  if (el.hasAttribute("tooltip-id")) {
    const tooltip = tooltipRegistry.get(el.getAttribute("tooltip-id")!);
    tooltip!.update(configuration);

    return;
  }

  const tooltip = new Tooltip(configuration);

  tooltipRegistry.set(tooltip.id ?? "", tooltip);
  el.setAttribute("tooltip-id", tooltip.id!);
}

/**
 * Directive for tooltips
 * Usage:
 * v-tooltip="{configuration}"
 * // configuration options:
 *  message: The text to be displayed.
 *  position: Position of the tooltip relative to the original element(top, bottom etc.).
 *  width: The width of the tooltip.
 *  showDelay: The delay before the tooltip is shown when the original element is hovered.
 *  hideDelay: The delay before the tooltip is removed when the original element is not hovered.
 *  disabled: Disables the tooltip and it wont be shown.
 *  appearance: Sets a additional css class "mt-tooltip--$appearance" for styling
 *  showOnDisabledElements: Shows the tooltip also if the original element is disabled. To achieve
 *      this a wrapper div element is created around the original element because the original element
 *      prevents mouse events when disabled.
 *
 * Examples:
 * // tooltip with default width of 200px and default position top:
 * v-tooltip="'Some text'"
 * // tooltip with position bottom by modifier:
 * v-tooltip.bottom="'Some text'"
 * // tooltip with position bottom and width 300px:
 * v-tooltip="{ message: 'Some Text', width: 200, position: 'bottom' }"
 * // Alternative tooltip with position bottom and width 300px:
 * v-tooltip.bottom="{ message: 'Some Text', width: 200 }"
 * // adjusting the delay:
 * v-tooltip.bottom="{ message: 'Some Text', width: 200, showDelay: 200, hideDelay: 300 }"
 *
 * *Note that the position variable has a higher priority as the modifier
 */
export default {
  beforeMount: (el: HTMLElement, binding) => {
    createOrUpdateTooltip(el, binding);
  },

  unmounted: (el: HTMLElement) => {
    if (el.hasAttribute("tooltip-id")) {
      const tooltip = tooltipRegistry.get(el.getAttribute("tooltip-id")!);
      tooltip!.hideTooltip();
    }
  },

  updated: (el: HTMLElement, binding) => {
    createOrUpdateTooltip(el, binding);
  },

  /**
   * Initialize the tooltip once it has been inserted to the DOM.
   */
  mounted: (el: HTMLElement) => {
    if (el.hasAttribute("tooltip-id")) {
      const tooltip = tooltipRegistry.get(el.getAttribute("tooltip-id")!);

      tooltip!.init();
    }
  },
} as ObjectDirective;
