/**
 * TEAM: frontend_infra
 * @flow strict
 */

import * as React from "react";
import {isEqual, debounce, throttle} from "lodash";
import {StyleSheet, css} from "aphrodite";
import {
  Transition,
  TransitionGroup,
  type TransitionStatus,
} from "react-transition-group";
import colors, {type Color} from "./colors";

const DEFAULT_TRANSITION_DURATION = 300;
const DEFAULT_ENTRY_EXIT_DURATION = 300;

/** Utility function that allows the highlighter to adapt to situations where
 * nested children are deeply nested in other scroll containers in children.
 *
 * Given the following example:
 * <Highlighter>
 *   <div class='element-with-margins'>
 *      <div class='scrollable-parent'>
 *         <div class='element-i-want-to-highlight'>
 *      </div>
 *   </div>
 *
 * This utility function, when getting the `offsetTop` of
 * 'element-i-want-to-highlight' will be able to account for the scroll distance
 * and subtract it to prevent the highlighter from render lower than it should
 * since is outside the scroll container. This also helps it adapt to margins of
 * parent elements since highlighter knowing only about the
 * 'element-i-want-to-highlight' element wouldn't be able to get this. */
function getScrollAwarePositionInfo(
  node: HTMLElement,
  {stopAt}: {|+stopAt: HTMLElement|}
): {|
  +top: number,
  +left: number,
  +requiresScrollUpdates: boolean,
|} {
  function getOffsetAncestors(
    node: ?HTMLElement,
    ancestorList: $ReadOnlyArray<HTMLElement> = []
  ): $ReadOnlyArray<HTMLElement> {
    if (
      node == null ||
      node.parentElement == null ||
      !(node.parentElement instanceof HTMLElement) ||
      node.parentElement.contains(stopAt) ||
      node.parentElement === stopAt
    ) {
      return ancestorList;
    }

    return getOffsetAncestors(
      node.parentElement,
      ancestorList.concat([node.parentElement])
    );
  }

  const ancestorTree = [node, ...getOffsetAncestors(node)];
  let top = node.offsetTop;
  let left = node.offsetLeft;

  for (let idx = 0; idx < ancestorTree.length; idx += 1) {
    const ancestor = ancestorTree[idx];

    top -= ancestor.scrollTop;
    left -= ancestor.scrollLeft;

    if (idx > 0 && ancestor === ancestorTree[idx - 1].offsetParent) {
      top += ancestor.offsetTop;
      left += ancestor.offsetLeft;
    }
  }

  return {
    top,
    left,
    requiresScrollUpdates: top !== node.offsetTop || left !== node.offsetLeft,
  };
}

export type HighlighterProps = {|
  /** any React.Node, Highlighter will add a highlight background alongside it's children */
  +children?: React.Node,
  /** color of the highlight */
  +color: Color,
  /** Element to be highlighted highlighted, does not have to be a direct descendent  */
  +highlightedElement: ?HTMLElement,
  /** Animation speed of transitions when the highlighter changes from one positions or size to another */
  +transitionDuration?: number,
  /** Animation used when highlights enter or exit */
  +entryExitAnimation?: "scaleX", // Only one supported animation at the time
  /** Duration of animation when highlights enter or exit */
  +entryExitAnimationDuration?: number,
  /** When true, updates the highlight for nested scrolling children that are highlighted  */
  +updateWithScroll?: number,
|};

/**
 * @short Highlighter adds a background fill to a selected element
 * @brandStatus V3
 * @status Stable
 * @category Interaction */
const Highlighter: React.AbstractComponent<HighlighterProps, mixed> =
  React.memo<HighlighterProps>(function Highlighter({
    children,
    color,
    highlightedElement,
    transitionDuration = DEFAULT_TRANSITION_DURATION,
    entryExitAnimation = "scaleX",
    entryExitAnimationDuration = DEFAULT_ENTRY_EXIT_DURATION,
  }: HighlighterProps) {
    const [sizeAndLocation, setSizeAndLocation] = React.useState();
    const [cssWaitComplete, setCSSWaitComplete] = React.useState(false);
    const {body} = typeof document !== "undefined" ? document : {};
    const [pageDimensions, setPageDimensions] = React.useState({
      height: body?.clientHeight,
      width: body?.clientWidth,
    });
    const previousSizeAndLocation = React.useRef(sizeAndLocation);
    const highlightWrapper: {|current: ?React.ElementRef<typeof HTMLElement>|} =
      React.useRef();
    const requiresScrollUpdating = React.useRef(false);
    // We use an internal selection ref to maintain an updated ref across renders even if passed in `selectionRef` is missing or changes
    const internalSelectionRef = React.useRef(highlightedElement);
    const measure = React.useCallback(() => {
      if (!cssWaitComplete) return;

      const storedElementToHighlight = internalSelectionRef.current;

      let newSizeAndLocation = null;

      if (
        storedElementToHighlight != null &&
        storedElementToHighlight.offsetHeight > 0 &&
        storedElementToHighlight.offsetWidth > 0 &&
        highlightWrapper.current?.parentElement != null &&
        highlightWrapper.current.parentElement instanceof HTMLElement
      ) {
        const scrollAwarePosition = getScrollAwarePositionInfo(
          storedElementToHighlight,
          {
            stopAt: highlightWrapper.current.parentElement,
          }
        );

        requiresScrollUpdating.current =
          scrollAwarePosition.requiresScrollUpdates;

        newSizeAndLocation = {
          left: scrollAwarePosition.left,
          top: scrollAwarePosition.top,
          height: storedElementToHighlight.offsetHeight,
          width: storedElementToHighlight.offsetWidth,
        };
      }

      if (!isEqual(previousSizeAndLocation.current, newSizeAndLocation)) {
        // State is async so track it twice, there is probably a more readable
        // way to do this but this works
        previousSizeAndLocation.current = newSizeAndLocation;
        setSizeAndLocation(newSizeAndLocation);
      }
    }, [cssWaitComplete]);

    internalSelectionRef.current = highlightedElement;

    React.useEffect(() => {
      let cssTimeoutId;

      // fixes bug where the background position is calculated before aphrodite styles are applied to the DOM
      if (!cssWaitComplete) {
        cssTimeoutId = setTimeout(setCSSWaitComplete, 0, true);
      }

      // We use this to check if window resized, if so update pageDimensions which is a dependency in our useLayoutEffect
      const handleResize = debounce(() => {
        const newPageDimensions = {
          height: body?.clientHeight,
          width: body?.clientWidth,
        };

        if (!isEqual(pageDimensions, newPageDimensions))
          setPageDimensions(newPageDimensions);
      }, 150);

      /* TODO(ctan): Move resize handler to a hook + context, potentially migrate WindowSizeContext */
      window.addEventListener("resize", handleResize);

      // On unmount, clear any timeouts
      return () => {
        clearTimeout(cssTimeoutId);

        window.removeEventListener("resize", handleResize);
      };
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    React.useEffect(() => {
      // Delay measuring since element dimensions may take a moment to stablize
      const measuringTimeoutId = setTimeout(measure, 0);

      return () => {
        clearTimeout(measuringTimeoutId);
      };
    }, [
      cssWaitComplete,
      children,
      pageDimensions,
      highlightedElement,
      measure,
    ]);

    React.useEffect(() => {
      const throttledScrollMeasure = throttle(() => {
        // Only measure on scroll if neccessary to save unnecessary rendering
        if (requiresScrollUpdating.current) measure();
      }, 100);
      window.addEventListener("scroll", throttledScrollMeasure, true);

      return () => {
        window.removeEventListener("scroll", throttledScrollMeasure);
      };
    }, [measure]);

    return (
      <>
        <div ref={highlightWrapper}>
          <Highlight
            color={color}
            transitionDuration={transitionDuration}
            entryExitAnimation={entryExitAnimation}
            entryExitAnimationDuration={entryExitAnimationDuration}
            left={sizeAndLocation?.left}
            top={sizeAndLocation?.top}
            width={sizeAndLocation?.width}
            height={sizeAndLocation?.height}
          />
        </div>

        {children}
      </>
    );
  });

const Highlight = React.memo(function Highlight({
  color,
  entryExitAnimation,
  entryExitAnimationDuration,
  transitionDuration,
  height = 0,
  width = 0,
  left,
  top,
}: {|
  +color: $PropertyType<HighlighterProps, "color">,
  +transitionDuration: $NonMaybeType<
    $PropertyType<HighlighterProps, "transitionDuration">
  >,
  +entryExitAnimation: $NonMaybeType<
    $PropertyType<HighlighterProps, "entryExitAnimation">
  >,
  +entryExitAnimationDuration: $NonMaybeType<
    $PropertyType<HighlighterProps, "entryExitAnimationDuration">
  >,
  +height: ?number,
  +width: ?number,
  +left: ?number,
  +top: ?number,
|}) {
  const active = left != null && top != null && width !== 0 && height !== 0;
  const sizeAndLocation = {
    height,
    width,
    left: left || 0,
    top: top || 0,
  };
  /** We cache the size and location for future renders where it may not be
   * available but we retain them for the exit animation */
  const cachedSizeAndLocation = React.useRef(sizeAndLocation);

  if (active) cachedSizeAndLocation.current = sizeAndLocation;

  return (
    <TransitionGroup>
      {active ? (
        <Transition
          /** NOTE(ctan): This 15ms entry duration is a hack due to issues with
           * this react-transition-group applying 'enter' and 'entering'
           * `animationState` (and hence the CSS states) too closely to each
           * other in time. This hack pushes the animation state back one level
           * to avoid the initial missing enter animation although we're not
           * using the animation state 'correctly'.
           *
           * My recommendation is to stop depending on react-transition-group
           * `animationState` altogether for animations, instead using something
           * like GSAP to apply tweens on react-transition-group triggers (e.g.
           * onEnter) */
          timeout={{enter: 15, exit: entryExitAnimationDuration}}
        >
          {animationState => (
            <div
              aria-hidden="true"
              className={css(styles.highlight)}
              style={{
                height: cachedSizeAndLocation.current.height,
                width: cachedSizeAndLocation.current.width,
                transform: `translate(${cachedSizeAndLocation.current.left}px, ${cachedSizeAndLocation.current.top}px)`,
                transition: `${transitionDuration}ms cubic-bezier(0.215, 0.610, 0.355, 1.000)`, // ease-out-cubic
              }}
            >
              <div
                className={css(styles.highlightFill)}
                style={{
                  ...getEntryExitStyles(
                    entryExitAnimation,
                    entryExitAnimationDuration
                  )[animationState],
                  backgroundColor: colors[color],
                }}
              />
            </div>
          )}
        </Transition>
      ) : null}
    </TransitionGroup>
  );
});

export default Highlighter;

const styles = StyleSheet.create({
  highlight: {
    position: "absolute",
    left: 0,
    top: 0,
    transitionProperty:
      "width, height, transform, borderRadius, backgroundColor",
    transformStyle: "flat",
    overflow: "hidden",
    pointerEvents: "none",
    userSelect: "none",
  },
  highlightFill: {
    position: "absolute",
    left: 0,
    top: 0,
    width: "100%",
    height: "100%",
  },
});

function getEntryExitStyles(
  type: $NonMaybeType<$PropertyType<HighlighterProps, "entryExitAnimation">>,
  duration: $NonMaybeType<
    $PropertyType<HighlighterProps, "entryExitAnimationDuration">
  >
) {
  const styles: {
    [$NonMaybeType<$PropertyType<HighlighterProps, "entryExitAnimation">>]: {
      [TransitionStatus | "default"]: {...},
    },
  } = {
    scaleX: {
      entering: {
        transform: "translateX(-100%)",
        transition: `transform ${duration}ms cubic-bezier(0.770, 0.000, 0.175, 1.000)`,
      },
      entered: {
        transform: "translateX(0%)",
        transition: `transform ${duration}ms cubic-bezier(0.770, 0.000, 0.175, 1.000)`,
      },
      exiting: {
        transform: "translateX(100%) ",
        transition: `transform ${duration}ms cubic-bezier(0.000, 0.000, 0.000, 1.000)`,
      },
      exited: {
        transform: "translateX(-100%)",
        transition: `transform ${duration}ms cubic-bezier(0.000, 0.000, 0.000, 1.000)`,
      },
    },
  };

  return styles[type];
}
