import styled from '@emotion/styled';
import {
  CSSProperties,
  ComponentProps,
  ReactNode,
  RefObject,
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
} from 'react';

import nearest from '@/utils/nearest';

// Types

export enum Axis {
  Horizontal,
  Vertical,
}

export type PanelSize = {
  unit: 'px' | 'fr';
  count: number;
};

// Elements

const PanelGroupWrapper = styled.div<{ axis: Axis }>`
  min-height: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: ${({ axis }) => (axis === Axis.Vertical ? 'column' : 'row')};
  &.animate {
    transition:
      grid-template-columns 0.2s ease,
      grid-template-rows 0.2s ease;
  }
`;

const PanelWrapper = styled.div`
  min-width: 0;
  min-height: 0;
  &[data-size-unit='fr'] {
    flex-grow: 1;
  }
  &[data-size-unit='px'] {
    flex-grow: 0;
  }
`;

const DividerWrapper = styled.div<{
  axis?: Axis;
  influence?: 'both' | 'before' | 'after' | 'neither';
}>`
  cursor: ${({ axis, influence }) =>
    influence === 'neither'
      ? ''
      : axis === Axis.Horizontal
        ? 'ew-resize'
        : 'ns-resize'};
`;

const FALLBACK_CHILDREN = <div />;

export const Panel = ({
  size,
  setSize,
  validSizeCounts,
  children = FALLBACK_CHILDREN,
  debounceSetSizeMS = 0,
  minSizePx = 0,
  maxSizePx = Infinity,
  style,
}: {
  size: PanelSize;
  validSizeCounts?: number[];
  setSize?: (newSize: PanelSize) => void;
  children?: ReactNode[] | ReactNode;
  debounceSetSizeMS?: number;
  minSizePx?: number;
  maxSizePx?: number;
  style?: CSSProperties;
}) => {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;

    if (!setSize) return;
    const onResize = () => {
      if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
      resizeTimeoutRef.current = setTimeout(
        () =>
          setSize({
            unit: wrapper.getAttribute('data-size-unit') as 'px' | 'fr',
            count: parseFloat(wrapper.getAttribute('data-size-count')!),
          }),
        debounceSetSizeMS
      );
    };

    wrapper.addEventListener('resize', onResize);

    return () => {
      wrapper.removeEventListener('resize', onResize);
    };
  }, [debounceSetSizeMS, setSize]);

  const axis = useContext(AxisContext);
  const combinedStyle = useMemo(
    () => ({
      ...(axis === Axis.Vertical
        ? { minHeight: minSizePx }
        : { minWidth: minSizePx }),
      ...style,
    }),
    [axis, minSizePx, style]
  );

  return (
    <PanelWrapper
      data-panel
      data-valid-size-counts={JSON.stringify(validSizeCounts)}
      data-size-unit={size.unit}
      data-size-count={size.count}
      data-min-size={minSizePx}
      data-max-size={maxSizePx}
      ref={wrapperRef}
      style={combinedStyle}
    >
      {children}
    </PanelWrapper>
  );
};

const AxisContext = createContext<Axis>(undefined as never);

const startLongClick = () => {
  document.body.style.userSelect = 'none';
  return () => {
    document.body.style.userSelect = '';
  };
};

export const Divider = ({
  children,
  influence = 'both',
}: {
  children?: ReactNode[] | ReactNode;
  influence?: 'before' | 'after' | 'both' | 'neither';
}) => {
  const axis = useContext(AxisContext);
  return (
    <DividerWrapper
      axis={axis}
      influence={influence}
      data-divider
      data-influence={influence}
    >
      {children}
    </DividerWrapper>
  );
};

export const PanelGroup = ({
  axis,
  children,
  onResize,
  setAnimating,
  frameCountRef,
  animateSizeChanges = true,
}: {
  axis: Axis;
  children: ReactNode[] | ReactNode;
  onResize?: () => void;
  setAnimating?: (animating: boolean) => void;
  frameCountRef?: RefObject<number>;
  animateSizeChanges?: boolean;
}) => {
  const wrapperRef = useRef<HTMLDivElement | null>(null);

  const getChildren = useCallback((wrapper: HTMLDivElement) => {
    const children = [
      ...wrapper.querySelectorAll('[data-divider], [data-panel]'),
    ].filter((c) => c.closest('[data-group-wrapper]') === wrapper);

    return children;
  }, []);

  const animatingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
    null
  );

  const animatingRef = useRef(false);

  const applySizes = useCallback(
    (wrapper: HTMLDivElement, children: Element[], animate: boolean) => {
      if (animatingTimeoutRef.current)
        clearTimeout(animatingTimeoutRef.current);
      if (animate) {
        animatingRef.current = true;
        if (frameCountRef) {
          const frameLoop = () => {
            if (!animatingRef.current) return;
            frameCountRef.current++;
            requestAnimationFrame(frameLoop);
          };
          frameLoop();
        }
        if (setAnimating) {
          setAnimating(true);
        }
        animatingTimeoutRef.current = setTimeout(() => {
          animatingRef.current = false;
          setAnimating?.(false);
        }, 200);
        wrapper.classList.add('animate');
      } else {
        if (setAnimating) {
          setAnimating(false);
        }
        animatingRef.current = false;
        wrapper.classList.remove('animate');
      }

      const gridSizes = children.reduce((a, c) => {
        if (c.hasAttribute('data-divider')) {
          a += ' auto';
        } else if (
          c.hasAttribute('data-size-unit') &&
          c.hasAttribute('data-size-count')
        ) {
          a += ` ${c.getAttribute('data-size-count')}${c.getAttribute('data-size-unit')}`;
        }
        return a;
      }, '');

      wrapper.setAttribute(
        'style',
        axis === Axis.Vertical
          ? `display: grid; grid-template-rows:${gridSizes}; grid-template-columns: 1fr;`
          : `display: grid; grid-template-columns:${gridSizes}; grid-template-rows: 1fr;`
      );

      if (onResize) {
        onResize();
      }
    },
    [axis, onResize]
  );

  const handleDividerMouseDown = useCallback(
    (wrapper: HTMLDivElement, children: Element[], event: MouseEvent) => {
      const endLongClick = startLongClick();
      const divider = (event.target as HTMLElement).closest('[data-divider]')!;
      const dividerInfluence = divider.getAttribute('data-influence') as
        | 'before'
        | 'after'
        | 'both'
        | 'neither';
      if (dividerInfluence === 'neither') return;

      const initialMouseCoordPx =
        axis === Axis.Vertical ? event.clientY : event.clientX;

      const dividerIndex = children.findIndex(
        (c) => c === divider || c.contains(divider)
      );

      const previousPanel = children[dividerIndex - 1] as HTMLElement;
      const nextPanel = children[dividerIndex + 1] as HTMLElement;

      const previousPanelSize = {
        unit: previousPanel.getAttribute('data-size-unit') as 'px' | 'fr',
        count: parseFloat(previousPanel.getAttribute('data-size-count')!),
        validCounts: JSON.parse(
          previousPanel.getAttribute('data-valid-size-counts')! || 'null'
        ),
      };

      const previousPanelSizePx =
        previousPanel.getBoundingClientRect()[
          axis === Axis.Vertical ? 'height' : 'width'
        ];

      const nextPanelSize = {
        unit: nextPanel.getAttribute('data-size-unit') as 'px' | 'fr',
        count: parseFloat(nextPanel.getAttribute('data-size-count')!),
        validCounts: JSON.parse(
          nextPanel.getAttribute('data-valid-size-counts')! || 'null'
        ),
      };

      const nextPanelSizePx =
        nextPanel.getBoundingClientRect()[
          axis === Axis.Vertical ? 'height' : 'width'
        ];

      const handleMouseMove = (e: MouseEvent) => {
        const mouseMovementPx =
          (axis === Axis.Vertical ? e.clientY : e.clientX) -
          initialMouseCoordPx;

        const previousPanelMinSize = Number(
          previousPanel.getAttribute('data-min-size') || 0
        );
        const previousPanelMaxSize = Number(
          previousPanel.getAttribute('data-max-size') || Infinity
        );

        const nextPanelMinSize = Number(
          nextPanel.getAttribute('data-min-size') || 0
        );
        const nextPanelMaxSize = Number(
          nextPanel.getAttribute('data-max-size') || Infinity
        );

        let effectiveMovementPx = mouseMovementPx;
        if (
          dividerInfluence !== 'after' &&
          previousPanelMinSize > 0 &&
          previousPanelSizePx + effectiveMovementPx < previousPanelMinSize
        ) {
          effectiveMovementPx = previousPanelMinSize - previousPanelSizePx;
        }
        if (
          dividerInfluence !== 'after' &&
          previousPanelMaxSize > 0 &&
          previousPanelSizePx + effectiveMovementPx > previousPanelMaxSize
        ) {
          effectiveMovementPx = previousPanelMaxSize - previousPanelSizePx;
        }
        if (
          dividerInfluence !== 'before' &&
          nextPanelMinSize > 0 &&
          nextPanelSizePx - effectiveMovementPx < nextPanelMinSize
        ) {
          effectiveMovementPx = nextPanelSizePx - nextPanelMinSize;
        }
        if (
          dividerInfluence !== 'before' &&
          nextPanelMaxSize > 0 &&
          nextPanelSizePx - effectiveMovementPx > nextPanelMaxSize
        ) {
          effectiveMovementPx = nextPanelSizePx - nextPanelMaxSize;
        }

        const previousPanelTargetSize =
          previousPanelSizePx + effectiveMovementPx;
        const nextPanelTargetSize = nextPanelSizePx - effectiveMovementPx;

        const previousPanelScaling =
          previousPanelTargetSize / previousPanelSizePx;
        const nextPanelScaling = nextPanelTargetSize / nextPanelSizePx;

        if (
          dividerInfluence !== 'after' &&
          (previousPanelSize.unit !== 'fr' ||
            previousPanelSize.unit === nextPanelSize.unit)
        ) {
          const idealCount = previousPanelSize.count * previousPanelScaling;
          const nearestValidCount = previousPanelSize.validCounts
            ? nearest(previousPanelSize.validCounts, idealCount)
            : idealCount;
          previousPanel.setAttribute(
            'data-size-count',
            nearestValidCount.toString()
          );
          previousPanel.dispatchEvent(new Event('resize'));
        }
        if (
          dividerInfluence !== 'before' &&
          (nextPanelSize.unit !== 'fr' ||
            previousPanelSize.unit === nextPanelSize.unit)
        ) {
          const idealCount = nextPanelSize.count * nextPanelScaling;
          const nearestValidCount = nextPanelSize.validCounts
            ? nearest(nextPanelSize.validCounts, idealCount)
            : idealCount;
          nextPanel.setAttribute(
            'data-size-count',
            nearestValidCount.toString()
          );
          nextPanel.dispatchEvent(new Event('resize'));
        }
        if (frameCountRef) {
          frameCountRef.current++;
        }
        applySizes(wrapper, children, false);
      };

      const handleMouseUp = () => {
        endLongClick();
        window.removeEventListener('mouseup', handleMouseUp);
        window.removeEventListener('mousemove', handleMouseMove);
      };

      window.addEventListener('mouseup', handleMouseUp);
      window.addEventListener('mousemove', handleMouseMove);
    },
    [applySizes, axis]
  );

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;

    const children = getChildren(wrapper);
    applySizes(wrapper, children, animateSizeChanges);

    const observers = children.map((child) => {
      const observer = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
          if (mutation.type === 'attributes') {
            // Example of accessing the element for which
            // event was triggered
            if (mutation.attributeName === 'data-size-count') {
              applySizes(wrapper, children, false);
            }
          }
        });
      });

      observer.observe(child, {
        attributes: true,
      });

      return observer;
    });

    const handleMouseDown = (e: MouseEvent) => {
      const targetElement = e.target as HTMLElement;
      if (
        !targetElement.matches('[data-divider], [data-divider] *') ||
        targetElement.closest('[data-group-wrapper]') !== wrapper
      )
        return;
      handleDividerMouseDown(wrapper, children, e);
    };

    wrapper.addEventListener('mousedown', handleMouseDown);
    return () => {
      observers.forEach((observer) => observer.disconnect());
      wrapper.removeEventListener('mousedown', handleMouseDown);
    };
  }, [children, getChildren, applySizes, handleDividerMouseDown]);

  const receiveWrapperRef = useCallback((ref: HTMLDivElement | null) => {
    wrapperRef.current = ref;
    if (ref) {
      applySizes(ref, getChildren(ref), false);
    }
  }, []);

  return (
    <AxisContext.Provider value={axis}>
      <PanelGroupWrapper axis={axis} data-group-wrapper ref={receiveWrapperRef}>
        {children}
      </PanelGroupWrapper>
    </AxisContext.Provider>
  );
};

export const VerticalPanelGroup = (
  props: Omit<ComponentProps<typeof PanelGroup>, 'axis'>
) => {
  return <PanelGroup axis={Axis.Vertical} {...props} />;
};

export const HorizontalPanelGroup = (
  props: Omit<ComponentProps<typeof PanelGroup>, 'axis'>
) => {
  return <PanelGroup axis={Axis.Horizontal} {...props} />;
};
