/*
 * 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, getRect, PositionResult} from './calculatePosition';
import {DOMAttributes, RefObject} from '@react-types/shared';
import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCloseOnScroll} from './useCloseOnScroll';
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 | null>,
  /**
   * The ref for the overlay element.
   */
  overlayRef: RefObject<Element | null>,
  /**
   * The ref for the arrow element.
   */
  arrowRef?: RefObject<Element | null>,
  /**
   * A ref for the scrollable region within the overlay.
   * @default overlayRef
   */
  scrollRef?: RefObject<Element | null>,
  /**
   * Whether the overlay should update its position automatically.
   * @default true
   */
  shouldUpdatePosition?: boolean,
  /** Handler that is called when the overlay should close. */
  onClose?: (() => void) | null,
  /**
   * 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 | null,
  /** The origin of the target in the overlay's coordinate system. Useful for animations. */
  triggerAnchorPoint: {x: number, y: number} | null,
  /** Updates the position of the overlay. */
  updatePosition(): void
}

interface ScrollAnchor {
  type: 'top' | 'bottom',
  offset: number
}

let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null;

/**
 * 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,
    targetRef,
    overlayRef,
    arrowRef,
    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 | null>(null);

  let deps = [
    shouldUpdatePosition,
    placement,
    overlayRef.current,
    targetRef.current,
    arrowRef?.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 || !boundaryElement) {
      return;
    }

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

    // Determine a scroll anchor based on the focused element.
    // This stores the offset of the anchor element from the scroll container
    // so it can be restored after repositioning. This way if the overlay height
    // changes, the focused element appears to stay in the same position.
    let anchor: ScrollAnchor | null = null;
    if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) {
      let anchorRect = document.activeElement?.getBoundingClientRect();
      let scrollRect = scrollRef.current.getBoundingClientRect();
      // Anchor from the top if the offset is in the top half of the scrollable element,
      // otherwise anchor from the bottom.
      anchor = {
        type: 'top',
        offset: (anchorRect?.top ?? 0) - scrollRect.top
      };
      if (anchor.offset > scrollRect.height / 2) {
        anchor.type = 'bottom';
        anchor.offset = (anchorRect?.bottom ?? 0) - scrollRect.bottom;
      }
    }

    // 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 || overlayRef.current,
      padding: containerPadding,
      shouldFlip,
      boundaryElement,
      offset,
      crossOffset,
      maxHeight,
      arrowSize: arrowSize ?? (arrowRef?.current ? getRect(arrowRef.current, true).width : 0),
      arrowBoundaryOffset
    });

    if (!position.position) {
      return;
    }

    // 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 = '';
    overlay.style.left = '';
    overlay.style.right = '';

    Object.keys(position.position).forEach(key => overlay.style[key] = (position.position!)[key] + 'px');
    overlay.style.maxHeight = position.maxHeight != null ?  position.maxHeight + 'px' : '';

    // Restore scroll position relative to anchor element.
    if (anchor && document.activeElement && scrollRef.current) {
      let anchorRect = document.activeElement.getBoundingClientRect();
      let scrollRect = scrollRef.current.getBoundingClientRect();
      let newOffset = anchorRect[anchor.type] - scrollRect[anchor.type];
      scrollRef.current.scrollTop += newOffset - anchor.offset;
    }

    // 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
  });

  // Update position when the target changes size (might need to flip).
  useResizeObserver({
    ref: targetRef,
    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: position ? 'absolute' : 'fixed',
        top: !position ? 0 : undefined,
        left: !position ? 0 : undefined,
        zIndex: 100000, // should match the z-index in ModalTrigger
        ...position?.position,
        maxHeight: position?.maxHeight ?? '100vh'
      }
    },
    placement: position?.placement ?? null,
    triggerAnchorPoint: position?.triggerAnchorPoint ?? null,
    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');
}
