/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 *
 * Modifications Copyright OpenSearch Contributors. See
 * GitHub history for details.
 */

/*
 * Licensed to Elasticsearch B.V. under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch B.V. licenses this file to you 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 React, {
  Component,
  cloneElement,
  Fragment,
  ReactElement,
  ReactNode,
  MouseEvent as ReactMouseEvent,
  KeyboardEvent,
} from 'react';
import classNames from 'classnames';

import { keysOf } from '../common';
import { OuiPortal } from '../portal';
import { OuiToolTipPopover } from './tool_tip_popover';
import { findPopoverPosition, htmlIdGenerator, keys } from '../../services';
import { enqueueStateChange } from '../../services/react';

import { OuiResizeObserver } from '../observer/resize_observer';

export type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left';

const positionsToClassNameMap: { [key in ToolTipPositions]: string } = {
  top: 'ouiToolTip--top',
  right: 'ouiToolTip--right',
  bottom: 'ouiToolTip--bottom',
  left: 'ouiToolTip--left',
};

export const POSITIONS = keysOf(positionsToClassNameMap);

export type ToolTipDelay = 'regular' | 'long';

const delayToMsMap: { [key in ToolTipDelay]: number } = {
  regular: 250,
  long: 250 * 5,
};

interface ToolTipStyles {
  top: number;
  left: number | 'auto';
  right?: number | 'auto';
  opacity?: number;
  visibility?: 'hidden';
}

const displayToClassNameMap = {
  inlineBlock: undefined,
  block: 'ouiToolTipAnchor--displayBlock',
};

const DEFAULT_TOOLTIP_STYLES: ToolTipStyles = {
  // position the tooltip content near the top-left
  // corner of the window so it can't create scrollbars
  // 50,50 because who knows what negative margins, padding, etc
  top: 50,
  left: 50,
  // just in case, avoid any potential flicker by hiding
  // the tooltip before it is positioned
  opacity: 0,
  // prevent accidental mouse interaction while positioning
  visibility: 'hidden',
};

export interface OuiToolTipProps {
  /**
   * Passes onto the the trigger.
   */
  anchorClassName?: string;
  /**
   * The in-view trigger for your tooltip.
   */
  children: ReactElement;
  /**
   * Passes onto the tooltip itself, not the trigger.
   */
  className?: string;
  /**
   * The main content of your tooltip.
   */
  content?: ReactNode;
  /**
   * Common display alternatives for the anchor wrapper
   */
  display?: keyof typeof displayToClassNameMap;
  /**
   * Delay before showing tooltip. Good for repeatable items.
   */
  delay: ToolTipDelay;
  /**
   * An optional title for your tooltip.
   */
  title?: ReactNode;
  /**
   * Unless you provide one, this will be randomly generated.
   */
  id?: string;
  /**
   * Suggested position. If there is not enough room for it this will be changed.
   */
  position: ToolTipPositions;

  /**
   * If supplied, called when mouse movement causes the tool tip to be
   * hidden.
   */
  onMouseOut?: (event: ReactMouseEvent<HTMLSpanElement, MouseEvent>) => void;
}

interface State {
  visible: boolean;
  calculatedPosition: ToolTipPositions;
  toolTipStyles: ToolTipStyles;
  arrowStyles: undefined | { left: number; top: number };
  id: string;
}

export class OuiToolTip extends Component<OuiToolTipProps, State> {
  _isMounted = false;
  anchor: null | HTMLElement = null;
  popover: null | HTMLElement = null;
  private timeoutId?: ReturnType<typeof setTimeout>;

  state: State = {
    visible: false,
    calculatedPosition: this.props.position,
    toolTipStyles: DEFAULT_TOOLTIP_STYLES,
    arrowStyles: undefined,
    id: this.props.id || htmlIdGenerator()(),
  };

  static defaultProps: Partial<OuiToolTipProps> = {
    position: 'top',
    delay: 'regular',
  };

  clearAnimationTimeout = () => {
    if (this.timeoutId) {
      this.timeoutId = clearTimeout(this.timeoutId) as undefined;
    }
  };

  componentDidMount() {
    this._isMounted = true;
  }

  componentWillUnmount() {
    this.clearAnimationTimeout();
    this._isMounted = false;
    window.removeEventListener('mousemove', this.hasFocusMouseMoveListener);
  }

  componentDidUpdate(prevProps: OuiToolTipProps, prevState: State) {
    if (prevState.visible === false && this.state.visible === true) {
      requestAnimationFrame(this.testAnchor);
    }
  }

  testAnchor = () => {
    // when the tooltip is visible, this checks if the anchor is still part of document
    // this fixes when the react root is removed from the dom without unmounting
    // https://github.com/elastic/eui/issues/1105
    if (document.body.contains(this.anchor) === false) {
      // the anchor is no longer part of `document`
      this.hideToolTip();
    } else {
      if (this.state.visible) {
        // if still visible, keep checking
        requestAnimationFrame(this.testAnchor);
      }
    }
  };

  setPopoverRef = (ref: HTMLElement) => {
    this.popover = ref;

    // if the popover has been unmounted, clear
    // any previous knowledge about its size
    if (ref == null) {
      this.setState({
        toolTipStyles: DEFAULT_TOOLTIP_STYLES,
        arrowStyles: undefined,
      });
    }
  };

  showToolTip = () => {
    if (!this.timeoutId) {
      this.timeoutId = setTimeout(() => {
        enqueueStateChange(() => this.setState({ visible: true }));
      }, delayToMsMap[this.props.delay]);
    }
  };

  positionToolTip = () => {
    const requestedPosition = this.props.position;

    if (!this.anchor || !this.popover) {
      return;
    }

    const { position, left, top, arrow } = findPopoverPosition({
      anchor: this.anchor,
      popover: this.popover,
      position: requestedPosition,
      offset: 16, // offset popover 16px from the anchor
      arrowConfig: {
        arrowWidth: 12,
        arrowBuffer: 4,
      },
    });

    // If encroaching the right edge of the window:
    // When `props.content` changes and is longer than `prevProps.content`, the tooltip width remains and
    // the resizeObserver callback will fire twice (once for vertical resize caused by text line wrapping,
    // once for a subsequent position correction) and cause a flash rerender and reposition.
    // To prevent this, we can orient from the right so that text line wrapping does not occur, negating
    // the second resizeObserver callback call.
    const windowWidth =
      document.documentElement.clientWidth || window.innerWidth;
    const useRightValue = windowWidth / 2 < left;

    const toolTipStyles: ToolTipStyles = {
      top,
      left: useRightValue ? 'auto' : left,
      right: useRightValue
        ? windowWidth - left - this.popover.offsetWidth
        : 'auto',
    };

    this.setState({
      visible: true,
      calculatedPosition: position,
      toolTipStyles,
      arrowStyles: arrow,
    });
  };

  hideToolTip = () => {
    this.clearAnimationTimeout();
    enqueueStateChange(() => {
      if (this._isMounted) {
        this.setState({ visible: false });
      }
    });
  };

  hasFocusMouseMoveListener = () => {
    this.hideToolTip();
    window.removeEventListener('mousemove', this.hasFocusMouseMoveListener);
  };

  onKeyUp = (event: KeyboardEvent<HTMLSpanElement>) => {
    if (event.key === keys.TAB) {
      window.addEventListener('mousemove', this.hasFocusMouseMoveListener);
    }
  };

  onMouseOut = (event: ReactMouseEvent<HTMLSpanElement, MouseEvent>) => {
    // Prevent mousing over children from hiding the tooltip by testing for whether the mouse has
    // left the anchor for a non-child.
    if (
      this.anchor === event.relatedTarget ||
      (this.anchor != null &&
        !this.anchor.contains(event.relatedTarget as Node))
    ) {
      this.hideToolTip();
    }

    if (this.props.onMouseOut) {
      this.props.onMouseOut(event);
    }
  };

  render() {
    const {
      children,
      className,
      anchorClassName,
      content,
      title,
      delay,
      display = 'inlineBlock',
      ...rest
    } = this.props;

    const { arrowStyles, id, toolTipStyles, visible } = this.state;

    const classes = classNames(
      'ouiToolTip',
      positionsToClassNameMap[this.state.calculatedPosition],
      className
    );

    const anchorClasses = classNames(
      'ouiToolTipAnchor',
      display ? displayToClassNameMap[display] : null,
      anchorClassName
    );

    let tooltip;
    if (visible && (content || title)) {
      tooltip = (
        <OuiPortal>
          <OuiToolTipPopover
            className={classes}
            style={toolTipStyles}
            positionToolTip={this.positionToolTip}
            popoverRef={this.setPopoverRef}
            title={title}
            id={id}
            role="tooltip"
            {...rest}>
            <div style={arrowStyles} className="ouiToolTip__arrow" />
            <OuiResizeObserver onResize={this.positionToolTip}>
              {(resizeRef) => <div ref={resizeRef}>{content}</div>}
            </OuiResizeObserver>
          </OuiToolTipPopover>
        </OuiPortal>
      );
    }

    const anchor = (
      // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
      <span
        ref={(anchor) => (this.anchor = anchor)}
        className={anchorClasses}
        onMouseOver={this.showToolTip}
        onMouseOut={this.onMouseOut}
        onKeyUp={(event) => {
          this.onKeyUp(event);
        }}>
        {/**
         * Re: jsx-a11y/mouse-events-have-key-events
         * We apply onFocus, onBlur, etc to the children element because that's the element
         * the user will be interacting with, as opposed to the enclosing anchor element.
         * For example, if the inner component is a button and the user tabs to it, we want
         * the enter key to trigger the button. That won't work if the enclosing anchor
         * element has focus.
         */}
        {cloneElement(children, {
          onFocus: this.showToolTip,
          onBlur: this.hideToolTip,
          ...(visible && { 'aria-describedby': this.state.id }),
        })}
      </span>
    );

    return (
      <Fragment>
        {anchor}
        {tooltip}
      </Fragment>
    );
  }
}