/*
 * Copyright 2020 Adobe. All rights reserved.
 * This file is licensed 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 REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {calculatePosition, PositionResult} from './calculatePosition';
import {DOMAttributes} from '@react-types/shared';
import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
import {RefObject, useCallback, useEffect, useRef, useState} from 'react';
import {useCloseOnScroll} from './useCloseOnScroll';
import {useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {useLocale} from '@react-aria/i18n';

export interface AriaPositionProps extends PositionProps {
  /**
   * Cross size of the overlay arrow in pixels.
   * @default 0
   */
  arrowSize?: number,
  /**
   * Element that that serves as the positioning boundary.
   * @default document.body
   */
  boundaryElement?: Element,
  /**
   * The ref for the element which the overlay positions itself with respect to.
   */
  targetRef: RefObject<Element>,
  /**
   * The ref for the overlay element.
   */
  overlayRef: RefObject<Element>,
  /**
   * A ref for the scrollable region within the overlay.
   * @default overlayRef
   */
  scrollRef?: RefObject<Element>,
  /**
   * Whether the overlay should update its position automatically.
   * @default true
   */
  shouldUpdatePosition?: boolean,
  /** Handler that is called when the overlay should close. */
  onClose?: () => void,
  /**
   * The maxHeight specified for the overlay element.
   * By default, it will take all space up to the current viewport height.
   */
  maxHeight?: number,
  /**
   * The minimum distance the arrow's edge should be from the edge of the overlay element.
   * @default 0
   */
  arrowBoundaryOffset?: number
}

export interface PositionAria {
  /** Props for the overlay container element. */
  overlayProps: DOMAttributes,
  /** Props for the overlay tip arrow if any. */
  arrowProps: DOMAttributes,
  /** Placement of the overlay with respect to the overlay trigger. */
  placement: PlacementAxis,
  /** Updates the position of the overlay. */
  updatePosition(): void
}

// @ts-ignore
let visualViewport = typeof document !== 'undefined' && window.visualViewport;

/**
 * Handles positioning overlays like popovers and menus relative to a trigger
 * element, and updating the position when the window resizes.
 */
export function useOverlayPosition(props: AriaPositionProps): PositionAria {
  let {direction} = useLocale();
  let {
    arrowSize = 0,
    targetRef,
    overlayRef,
    scrollRef = overlayRef,
    placement = 'bottom' as Placement,
    containerPadding = 12,
    shouldFlip = true,
    boundaryElement = typeof document !== 'undefined' ? document.body : null,
    offset = 0,
    crossOffset = 0,
    shouldUpdatePosition = true,
    isOpen = true,
    onClose,
    maxHeight,
    arrowBoundaryOffset = 0
  } = props;
  let [position, setPosition] = useState<PositionResult>({
    position: {},
    arrowOffsetLeft: undefined,
    arrowOffsetTop: undefined,
    maxHeight: undefined,
    placement: undefined
  });

  let deps = [
    shouldUpdatePosition,
    placement,
    overlayRef.current,
    targetRef.current,
    scrollRef.current,
    containerPadding,
    shouldFlip,
    boundaryElement,
    offset,
    crossOffset,
    isOpen,
    direction,
    maxHeight,
    arrowBoundaryOffset,
    arrowSize
  ];

  // Note, the position freezing breaks if body sizes itself dynamicly with the visual viewport but that might
  // just be a non-realistic use case
  // Upon opening a overlay, record the current visual viewport scale so we can freeze the overlay styles
  let lastScale = useRef(visualViewport?.scale);
  useEffect(() => {
    if (isOpen) {
      lastScale.current = visualViewport?.scale;
    }
  }, [isOpen]);

  let updatePosition = useCallback(() => {
    if (shouldUpdatePosition === false || !isOpen || !overlayRef.current || !targetRef.current || !scrollRef.current || !boundaryElement) {
      return;
    }

    if (visualViewport?.scale !== lastScale.current) {
      return;
    }

    // Always reset the overlay's previous max height if not defined by the user so that we can compensate for
    // RAC collections populating after a second render and properly set a correct max height + positioning when it populates.
    let overlay = (overlayRef.current as HTMLElement);
    if (!maxHeight && overlayRef.current) {
      overlay.style.top = '0px';
      overlay.style.bottom = '';
      overlay.style.maxHeight = (window.visualViewport?.height ?? window.innerHeight) + 'px';
    }

    let position = calculatePosition({
      placement: translateRTL(placement, direction),
      overlayNode: overlayRef.current,
      targetNode: targetRef.current,
      scrollNode: scrollRef.current,
      padding: containerPadding,
      shouldFlip,
      boundaryElement,
      offset,
      crossOffset,
      maxHeight,
      arrowSize,
      arrowBoundaryOffset
    });

    // Modify overlay styles directly so positioning happens immediately without the need of a second render
    // This is so we don't have to delay autoFocus scrolling or delay applying preventScroll for popovers
    overlay.style.top = '';
    overlay.style.bottom = '';
    Object.keys(position.position).forEach(key => overlay.style[key] = position.position[key] + 'px');
    overlay.style.maxHeight = position.maxHeight != null ?  position.maxHeight + 'px' : undefined;

    // Trigger a set state for a second render anyway for arrow positioning
    setPosition(position);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  // Update position when anything changes
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useLayoutEffect(updatePosition, deps);

  // Update position on window resize
  useResize(updatePosition);

  // Update position when the overlay changes size (might need to flip).
  useResizeObserver({
    ref: overlayRef,
    onResize: updatePosition
  });

  // Reposition the overlay and do not close on scroll while the visual viewport is resizing.
  // This will ensure that overlays adjust their positioning when the iOS virtual keyboard appears.
  let isResizing = useRef(false);
  useLayoutEffect(() => {
    let timeout: ReturnType<typeof setTimeout>;
    let onResize = () => {
      isResizing.current = true;
      clearTimeout(timeout);

      timeout = setTimeout(() => {
        isResizing.current = false;
      }, 500);

      updatePosition();
    };

    // Only reposition the overlay if a scroll event happens immediately as a result of resize (aka the virtual keyboard has appears)
    // We don't want to reposition the overlay if the user has pinch zoomed in and is scrolling the viewport around.
    let onScroll = () => {
      if (isResizing.current) {
        onResize();
      }
    };

    visualViewport?.addEventListener('resize', onResize);
    visualViewport?.addEventListener('scroll', onScroll);
    return () => {
      visualViewport?.removeEventListener('resize', onResize);
      visualViewport?.removeEventListener('scroll', onScroll);
    };
  }, [updatePosition]);

  let close = useCallback(() => {
    if (!isResizing.current) {
      onClose();
    }
  }, [onClose, isResizing]);

  // When scrolling a parent scrollable region of the trigger (other than the body),
  // we hide the popover. Otherwise, its position would be incorrect.
  useCloseOnScroll({
    triggerRef: targetRef,
    isOpen,
    onClose: onClose && close
  });

  return {
    overlayProps: {
      style: {
        position: 'absolute',
        zIndex: 100000, // should match the z-index in ModalTrigger
        ...position.position,
        maxHeight: position.maxHeight
      }
    },
    placement: position.placement,
    arrowProps: {
      'aria-hidden': 'true',
      role: 'presentation',
      style: {
        left: position.arrowOffsetLeft,
        top: position.arrowOffsetTop
      }
    },
    updatePosition
  };
}

function useResize(onResize) {
  useLayoutEffect(() => {
    window.addEventListener('resize', onResize, false);
    return () => {
      window.removeEventListener('resize', onResize, false);
    };
  }, [onResize]);
}

function translateRTL(position, direction) {
  if (direction === 'rtl') {
    return position.replace('start', 'right').replace('end', 'left');
  }
  return position.replace('start', 'left').replace('end', 'right');
}
