// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import useResizeObserver from "@react-hook/resize-observer";
import clsx from "clsx";
import { toPng } from "html-to-image";
import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import React, {
    CSSProperties,
    ReactNode,
    Suspense,
    memo,
    useCallback,
    useEffect,
    useLayoutEffect,
    useMemo,
    useRef,
    useState,
} from "react";
import { DropTargetMonitor, useDrag, useDragLayer, useDrop } from "react-dnd";
import { debounce, throttle } from "throttle-debounce";
import { useDevicePixelRatio } from "use-device-pixel-ratio";
import { globalLayoutTransformsMap } from "./layoutAtom";
import { findNode, totalChildrenSize } from "./layoutNode";
import { layoutTreeStateReducer } from "./layoutState";
import {
    ContentRenderer,
    LayoutNode,
    LayoutTreeAction,
    LayoutTreeActionType,
    LayoutTreeComputeMoveNodeAction,
    LayoutTreeDeleteNodeAction,
    LayoutTreeMoveNodeAction,
    LayoutTreeResizeNodeAction,
    LayoutTreeSetPendingAction,
    LayoutTreeState,
    LayoutTreeSwapNodeAction,
    PreviewRenderer,
    WritableLayoutTreeStateAtom,
} from "./model";
import { NodeRefMap } from "./nodeRefMap";
import "./tilelayout.less";
import { Dimensions, FlexDirection, determineDropDirection, setTransform } from "./utils";

/**
 * contains callbacks and information about the contents (or styling) of of the TileLayout
 * nothing in here is specific to the TileLayout itself
 */
export interface TileLayoutContents<T> {
    /**
     * A callback that accepts the data from the leaf node and displays the leaf contents to the user.
     */
    renderContent: ContentRenderer<T>;
    /**
     * A callback that accepts the data from the leaf node and returns a preview that can be shown when the user drags a node.
     */
    renderPreview?: PreviewRenderer<T>;
    /**
     * A callback that is called when a node gets deleted from the LayoutTreeState.
     * @param data The contents of the node that was deleted.
     */
    onNodeDelete?: (data: T) => Promise<void>;
    /**
     * The class name to use for the top-level div of the tile layout.
     */
    className?: string;

    /**
     * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook.
     * @returns The cursor position relative to the current window.
     */
    getCursorPoint?: () => Point;

    /**
     * tabId this TileLayout is associated with
     */
    tabId?: string;
}

export interface TileLayoutProps<T> {
    /**
     * The atom containing the layout tree state.
     */
    layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>;

    /**
     * callbacks and information about the contents (or styling) of the TileLayout or contents
     */
    contents: TileLayoutContents<T>;

    /**
     * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook.
     * @returns The cursor position relative to the current window.
     */
    getCursorPoint?: () => Point;
}

const DragPreviewWidth = 300;
const DragPreviewHeight = 300;

function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint }: TileLayoutProps<T>) {
    const overlayContainerRef = useRef<HTMLDivElement>(null);
    const displayContainerRef = useRef<HTMLDivElement>(null);
    const jotaiStore = useStore();
    const layoutTreeState = useAtomValue(layoutTreeStateAtom);
    const [nodeRefsAtom] = useState<PrimitiveAtom<NodeRefMap>>(atom(new NodeRefMap()));
    const nodeRefs = useAtomValue(nodeRefsAtom);
    const dispatch = useCallback(
        (action: LayoutTreeAction) => {
            const currentState = jotaiStore.get(layoutTreeStateAtom);
            jotaiStore.set(layoutTreeStateAtom, layoutTreeStateReducer(currentState, action));
        },
        [layoutTreeStateAtom, jotaiStore]
    );
    const [showOverlayAtom] = useState<PrimitiveAtom<boolean>>(atom(false));
    const [showOverlay, setShowOverlay] = useAtom(showOverlayAtom);

    function onPointerOver() {
        setShowOverlay(true);
    }

    const [overlayTransform, setOverlayTransform] = useState<CSSProperties>();
    const [layoutLeafTransforms, setLayoutLeafTransformsRaw] = useState<Record<string, CSSProperties>>({});

    const setLayoutLeafTransforms = (transforms: Record<string, CSSProperties>) => {
        globalLayoutTransformsMap.set(contents.tabId, transforms);
        setLayoutLeafTransformsRaw(transforms);
    };

    const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({
        activeDrag: monitor.isDragging(),
        dragClientOffset: monitor.getClientOffset(),
    }));

    // Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture
    // because that conflicts with the DnD layer.
    useEffect(
        debounce(100, () => {
            const cursorPoint = getCursorPoint?.() ?? dragClientOffset;
            if (cursorPoint && displayContainerRef.current) {
                const displayContainerRect = displayContainerRef.current.getBoundingClientRect();
                const normalizedX = cursorPoint.x - displayContainerRect.x;
                const normalizedY = cursorPoint.y - displayContainerRect.y;
                if (
                    normalizedX <= 0 ||
                    normalizedX >= displayContainerRect.width ||
                    normalizedY <= 0 ||
                    normalizedY >= displayContainerRect.height
                ) {
                    dispatch({ type: LayoutTreeActionType.ClearPendingAction });
                }
            }
        }),
        [dragClientOffset]
    );

    /**
     * Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging.
     */
    const updateTransforms = useCallback(
        debounce(30, () => {
            // TODO: janky way of preventing updates while a node resize is underway
            if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) return;
            if (overlayContainerRef.current && displayContainerRef.current) {
                const displayBoundingRect = displayContainerRef.current.getBoundingClientRect();
                // console.log("displayBoundingRect", displayBoundingRect);
                const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();

                const newLayoutLeafTransforms: Record<string, CSSProperties> = {};

                // console.log(
                //     "nodeRefs",
                //     nodeRefs,
                //     "layoutLeafs",
                //     layoutTreeState.leafs,
                //     "layoutTreeState",
                //     layoutTreeState
                // );

                for (const leaf of layoutTreeState.leafs) {
                    const leafRef = nodeRefs.get(leaf.id);
                    // console.log("current leafRef", leafRef.current);
                    if (leafRef?.current) {
                        if (leaf.id === layoutTreeState.magnifiedNodeId) {
                            const transform = setTransform(
                                {
                                    top: displayBoundingRect.height * 0.05,
                                    left: displayBoundingRect.width * 0.05,
                                    width: displayBoundingRect.width * 0.9,
                                    height: displayBoundingRect.height * 0.9,
                                },
                                true
                            );
                            newLayoutLeafTransforms[leaf.id] = transform;
                            continue;
                        }

                        const leafBounding = leafRef.current.getBoundingClientRect();
                        const transform = setTransform({
                            top: leafBounding.top - overlayBoundingRect.top,
                            left: leafBounding.left - overlayBoundingRect.left,
                            width: leafBounding.width,
                            height: leafBounding.height,
                        });
                        newLayoutLeafTransforms[leaf.id] = transform;
                    } else {
                        console.warn("missing leaf", leaf.id);
                    }
                }

                setLayoutLeafTransforms(newLayoutLeafTransforms);

                const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height;
                // console.log("overlayOffset", newOverlayOffset);
                setOverlayTransform(
                    setTransform(
                        {
                            top: activeDrag || showOverlay ? 0 : newOverlayOffset,
                            left: 0,
                            width: overlayBoundingRect.width,
                            height: overlayBoundingRect.height,
                        },
                        false
                    )
                );
            }
        }),
        [activeDrag, showOverlay, overlayContainerRef, displayContainerRef, layoutTreeState.leafs, nodeRefs.generation]
    );

    // Update the transforms whenever we drag something and whenever the layout updates.
    useLayoutEffect(() => {
        updateTransforms();
    }, [updateTransforms]);

    useResizeObserver(overlayContainerRef, () => updateTransforms());

    // Ensure that we don't see any jostling in the layout when we're rendering it the first time.
    // `animate` will be disabled until after the transforms have all applied the first time.
    const [animate, setAnimate] = useState(false);
    useEffect(() => {
        setTimeout(() => {
            setAnimate(true);
        }, 50);
    }, []);

    const onLeafClose = useCallback(
        async (node: LayoutNode<T>) => {
            // console.log("onLeafClose", node);
            const deleteAction: LayoutTreeDeleteNodeAction = {
                type: LayoutTreeActionType.DeleteNode,
                nodeId: node.id,
            };
            // console.log("calling dispatch", deleteAction);
            dispatch(deleteAction);
            // console.log("calling onNodeDelete", node);
            await contents.onNodeDelete?.(node.data);
            // console.log("node deleted");
        },
        [contents.onNodeDelete, dispatch]
    );

    const onLeafMagnifyToggle = useCallback(
        (node: LayoutNode<T>) => {
            const action = {
                type: LayoutTreeActionType.MagnifyNodeToggle,
                nodeId: node.id,
            };

            dispatch(action);
        },
        [dispatch]
    );

    return (
        <Suspense>
            <div className={clsx("tile-layout", contents.className, { animate })} onPointerOver={onPointerOver}>
                <div key="display" ref={displayContainerRef} className="display-container">
                    <DisplayNodesWrapper
                        contents={contents}
                        ready={animate}
                        onLeafMagnifyToggle={onLeafMagnifyToggle}
                        onLeafClose={onLeafClose}
                        layoutTreeState={layoutTreeState}
                        layoutLeafTransforms={layoutLeafTransforms}
                    />
                </div>
                <Placeholder
                    key="placeholder"
                    layoutTreeState={layoutTreeState}
                    overlayContainerRef={overlayContainerRef}
                    nodeRefsAtom={nodeRefsAtom}
                    style={{ top: 10000, ...overlayTransform }}
                />
                <div
                    key="overlay"
                    ref={overlayContainerRef}
                    className="overlay-container"
                    style={{ top: 10000, ...overlayTransform }}
                >
                    {!layoutTreeState?.rootNode ? null : (
                        <OverlayNode
                            layoutNode={layoutTreeState.rootNode}
                            layoutTreeState={layoutTreeState}
                            dispatch={dispatch}
                            nodeRefsAtom={nodeRefsAtom}
                            showOverlayAtom={showOverlayAtom}
                            siblingSize={layoutTreeState.rootNode?.size}
                        />
                    )}
                </div>
            </div>
        </Suspense>
    );
}

export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;

interface DisplayNodesWrapperProps<T> {
    /**
     * The layout tree state.
     */
    layoutTreeState: LayoutTreeState<T>;
    /**
     * contains callbacks and information about the contents (or styling) of of the TileLayout
     */
    contents: TileLayoutContents<T>;
    /**
     * A callback that is called when a leaf node gets closed.
     * @param node The node that is closed.
     */
    onLeafClose: (node: LayoutNode<T>) => void;

    /**
     * A callback that is called when a leaf's magnification is being toggled.
     * @param node The node that is being magnified or un-magnified.
     */
    onLeafMagnifyToggle: (node: LayoutNode<T>) => void;

    /**
     * A series of CSS properties used to display a leaf node with the correct dimensions and position, as determined from its corresponding OverlayNode.
     */
    layoutLeafTransforms: Record<string, CSSProperties>;
    /**
     * Determines whether the leaf nodes are ready to be displayed to the user.
     */
    ready: boolean;
}

const DisplayNodesWrapper = memo(
    <T,>({
        layoutTreeState,
        contents,
        onLeafClose,
        onLeafMagnifyToggle,
        layoutLeafTransforms,
        ready,
    }: DisplayNodesWrapperProps<T>) => {
        if (!layoutLeafTransforms) {
            return null;
        }
        return layoutTreeState.leafs.map((leaf) => {
            return (
                <DisplayNode
                    className={clsx({ magnified: layoutTreeState.magnifiedNodeId === leaf.id })}
                    key={leaf.id}
                    layoutNode={leaf}
                    contents={contents}
                    transform={layoutLeafTransforms[leaf.id]}
                    onLeafClose={onLeafClose}
                    onLeafMagnifyToggle={onLeafMagnifyToggle}
                    ready={ready}
                />
            );
        });
    }
);

interface DisplayNodeProps<T> {
    /**
     * The leaf node object, containing the data needed to display the leaf contents to the user.
     */
    layoutNode: LayoutNode<T>;

    /**
     * contains callbacks and information about the contents (or styling) of of the TileLayout
     */
    contents: TileLayoutContents<T>;

    /**
     * A callback that is called when a leaf's magnification is being toggled.
     * @param node The node that is being magnified or unmagnified.
     */
    onLeafMagnifyToggle: (node: LayoutNode<T>) => void;

    /**
     * A callback that is called when a leaf node gets closed.
     * @param node The node that is closed.
     */
    onLeafClose: (node: LayoutNode<T>) => void;
    /**
     * Determines whether a leaf's contents should be displayed to the user.
     */
    ready: boolean;

    /**
     * Any class names to add to the component.
     */
    className?: string;

    /**
     * A series of CSS properties used to display a leaf node with the correct dimensions and position, as determined from its corresponding OverlayNode.
     */
    transform: CSSProperties;
}

const dragItemType = "TILE_ITEM";

/**
 * The draggable and displayable portion of a leaf node in a layout tree.
 */
const DisplayNode = memo(
    <T,>({
        layoutNode,
        contents,
        transform,
        onLeafMagnifyToggle,
        onLeafClose,
        ready,
        className,
    }: DisplayNodeProps<T>) => {
        const tileNodeRef = useRef<HTMLDivElement>(null);
        const dragHandleRef = useRef<HTMLDivElement>(null);
        const previewRef = useRef<HTMLDivElement>(null);

        const devicePixelRatio = useDevicePixelRatio();

        const [{ isDragging }, drag, dragPreview] = useDrag(
            () => ({
                type: dragItemType,
                item: () => layoutNode,
                collect: (monitor) => ({
                    isDragging: monitor.isDragging(),
                }),
            }),
            [layoutNode]
        );

        const [previewElementGeneration, setPreviewElementGeneration] = useState(0);
        const previewElement = useMemo(() => {
            setPreviewElementGeneration(previewElementGeneration + 1);
            return (
                <div key="preview" className="tile-preview-container">
                    <div
                        className="tile-preview"
                        ref={previewRef}
                        style={{
                            width: DragPreviewWidth,
                            height: DragPreviewHeight,
                            transform: `scale(${1 / devicePixelRatio})`,
                        }}
                    >
                        {contents.renderPreview?.(layoutNode.data)}
                    </div>
                </div>
            );
        }, [contents.renderPreview, devicePixelRatio]);

        const [previewImage, setPreviewImage] = useState<HTMLImageElement>(null);
        const [previewImageGeneration, setPreviewImageGeneration] = useState(0);
        const generatePreviewImage = useCallback(() => {
            const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10;
            const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10;
            if (previewImage !== null && previewElementGeneration === previewImageGeneration) {
                dragPreview(previewImage, { offsetY, offsetX });
            } else if (previewRef.current) {
                setPreviewImageGeneration(previewElementGeneration);
                toPng(previewRef.current).then((url) => {
                    const img = new Image();
                    img.src = url;
                    setPreviewImage(img);
                    dragPreview(img, { offsetY, offsetX });
                });
            }
        }, [
            dragPreview,
            previewRef.current,
            previewElementGeneration,
            previewImageGeneration,
            previewImage,
            devicePixelRatio,
        ]);

        // Register the tile item as a draggable component
        useEffect(() => {
            drag(dragHandleRef);
        }, [drag, dragHandleRef.current]);

        const onClose = useCallback(() => {
            onLeafClose(layoutNode);
        }, [layoutNode, onLeafClose]);

        const onMagnifyToggle = useCallback(() => {
            onLeafMagnifyToggle(layoutNode);
        }, [layoutNode, onLeafMagnifyToggle]);

        const leafContent = useMemo(() => {
            return (
                layoutNode.data && (
                    <div key="leaf" className="tile-leaf">
                        {contents.renderContent(layoutNode.data, ready, onMagnifyToggle, onClose, dragHandleRef)}
                    </div>
                )
            );
        }, [layoutNode.data, ready, onClose]);

        return (
            <div
                className={clsx("tile-node", className, { dragging: isDragging })}
                ref={tileNodeRef}
                id={layoutNode.id}
                style={{
                    ...transform,
                }}
                onPointerEnter={generatePreviewImage}
                onPointerOver={(event) => event.stopPropagation()}
            >
                {leafContent}
                {previewElement}
            </div>
        );
    }
);

interface OverlayNodeProps<T> {
    /**
     * The layout node object corresponding to the OverlayNode.
     */
    layoutNode: LayoutNode<T>;
    /**
     * The layout tree state.
     */
    layoutTreeState: LayoutTreeState<T>;
    /**
     * The reducer function for mutating the layout tree state.
     * @param action The action to perform.
     */
    dispatch: (action: LayoutTreeAction) => void;

    nodeRefsAtom: PrimitiveAtom<NodeRefMap>;

    showOverlayAtom: PrimitiveAtom<boolean>;

    showResizeOverlay?: boolean;

    siblingSize: number;
}

/**
 * An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the
 * dimensions of the corresponding DisplayNode for each LayoutTreeState leaf.
 */
const OverlayNode = <T,>({
    layoutNode,
    layoutTreeState,
    dispatch,
    nodeRefsAtom,
    showOverlayAtom,
    showResizeOverlay,
    siblingSize,
}: OverlayNodeProps<T>) => {
    const overlayRef = useRef<HTMLDivElement>(null);
    const leafRef = useRef<HTMLDivElement>(null);

    const setShowOverlay = useSetAtom(showOverlayAtom);

    const setNodeRefs = useSetAtom(nodeRefsAtom);

    const [, drop] = useDrop(
        () => ({
            accept: dragItemType,
            canDrop: (_, monitor) => {
                const dragItem = monitor.getItem<LayoutNode<T>>();
                if (monitor.isOver({ shallow: true }) && dragItem?.id !== layoutNode.id) {
                    return true;
                }
                return false;
            },
            drop: (_, monitor) => {
                // console.log("drop start", layoutNode.id, layoutTreeState.pendingAction);
                if (!monitor.didDrop() && layoutTreeState.pendingAction) {
                    dispatch({
                        type: LayoutTreeActionType.CommitPendingAction,
                    });
                }
            },
            hover: throttle(30, (_, monitor: DropTargetMonitor<unknown, unknown>) => {
                if (monitor.isOver({ shallow: true })) {
                    if (monitor.canDrop()) {
                        const dragItem = monitor.getItem<LayoutNode<T>>();
                        // console.log("computing operation", layoutNode, dragItem, layoutTreeState.pendingAction);
                        dispatch({
                            type: LayoutTreeActionType.ComputeMove,
                            node: layoutNode,
                            nodeToMove: dragItem,
                            direction: determineDropDirection(
                                overlayRef.current?.getBoundingClientRect(),
                                monitor.getClientOffset()
                            ),
                        } as LayoutTreeComputeMoveNodeAction<T>);
                    } else {
                        dispatch({
                            type: LayoutTreeActionType.ClearPendingAction,
                        });
                    }
                }
            }),
        }),
        [overlayRef.current, layoutNode, layoutTreeState, dispatch]
    );

    // Register the tile item as a draggable component
    useEffect(() => {
        const layoutNodeId = layoutNode?.id;
        if (overlayRef?.current) {
            drop(overlayRef);
            setNodeRefs((nodeRefs) => {
                nodeRefs.set(layoutNodeId, overlayRef);
                return nodeRefs;
            });
        }

        return () => {
            setNodeRefs((nodeRefs) => {
                nodeRefs.delete(layoutNodeId);
                return nodeRefs;
            });
        };
    }, [overlayRef, layoutNode?.id]);

    function onPointerOverLeaf(event: React.PointerEvent<HTMLDivElement>) {
        event.stopPropagation();
        setShowOverlay(false);
    }

    const [resizeOnCurrentNode, setResizeOnCurrentNode] = useState(false);
    const [pendingSize, setPendingSize] = useState<number>(undefined);

    useLayoutEffect(() => {
        if (showResizeOverlay) {
            setResizeOnCurrentNode(false);
            setPendingSize(undefined);
            return;
        }
        if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) {
            const resizeAction = layoutTreeState.pendingAction as LayoutTreeResizeNodeAction;
            const resizeOperation = resizeAction?.resizeOperations?.find(
                (operation) => operation.nodeId === layoutNode.id
            );
            if (resizeOperation) {
                setResizeOnCurrentNode(true);
                setPendingSize(resizeOperation.size);
                return;
            }
        }
        setResizeOnCurrentNode(false);
        setPendingSize(undefined);
    }, [showResizeOverlay, layoutTreeState.pendingAction]);

    const generateChildren = () => {
        if (Array.isArray(layoutNode.children)) {
            const totalSize = totalChildrenSize(layoutNode);
            return layoutNode.children
                .map((childItem, i) => {
                    return [
                        <OverlayNode
                            key={childItem.id}
                            layoutNode={childItem}
                            layoutTreeState={layoutTreeState}
                            dispatch={dispatch}
                            nodeRefsAtom={nodeRefsAtom}
                            showOverlayAtom={showOverlayAtom}
                            showResizeOverlay={resizeOnCurrentNode || showResizeOverlay}
                            siblingSize={totalSize}
                        />,
                        <ResizeHandle
                            key={`resize-${layoutNode.id}-${i}`}
                            parentNode={layoutNode}
                            index={i}
                            dispatch={dispatch}
                            nodeRefsAtom={nodeRefsAtom}
                        />,
                    ];
                })
                .flat()
                .slice(0, -1);
        } else {
            return [<div ref={leafRef} key="leaf" className="overlay-leaf" onPointerOver={onPointerOverLeaf}></div>];
        }
    };

    if (!layoutNode) {
        return null;
    }

    const sizePercentage = ((pendingSize ?? layoutNode.size) / siblingSize) * 100;

    return (
        <div
            ref={overlayRef}
            className={clsx("overlay-node", { resizing: resizeOnCurrentNode || showResizeOverlay })}
            id={layoutNode.id}
            style={{
                flexBasis: `${sizePercentage.toPrecision(5)}%`,
                flexDirection: layoutNode.flexDirection,
            }}
        >
            {generateChildren()}
        </div>
    );
};

interface ResizeHandleProps<T> {
    parentNode: LayoutNode<T>;
    index: number;
    dispatch: (action: LayoutTreeAction) => void;
    nodeRefsAtom: PrimitiveAtom<NodeRefMap>;
}

const ResizeHandle = <T,>({ parentNode, index, dispatch, nodeRefsAtom }: ResizeHandleProps<T>) => {
    const resizeHandleRef = useRef<HTMLDivElement>(null);

    // The pointer currently captured, or undefined.
    const [trackingPointer, setTrackingPointer] = useState<number>(undefined);
    const nodeRefs = useAtomValue(nodeRefsAtom);

    // Cached values set in startResize
    const [combinedNodesRect, setCombinedNodesRect] = useState<Dimensions>();
    const [gapSize, setGapSize] = useState(0);
    const [pixelToSizeRatio, setPixelToSizeRatio] = useState(0);

    // Precompute some values that will be needed by the handlePointerMove function
    const startResize = () => {
        const parentRef = nodeRefs.get(parentNode.id);
        const node1Ref = nodeRefs.get(parentNode.children![index].id);
        const node2Ref = nodeRefs.get(parentNode.children![index + 1].id);
        if (parentRef?.current && node1Ref?.current && node2Ref?.current) {
            const parentIsRow = parentNode.flexDirection === FlexDirection.Row;
            const parentRectNew = parentRef.current.getBoundingClientRect();
            const node1Rect = node1Ref.current.getBoundingClientRect();
            const node2Rect = node2Ref.current.getBoundingClientRect();
            const totalSiblingSize = totalChildrenSize(parentNode);
            const gapSize = parentIsRow
                ? node2Rect.left - (node1Rect.left + node1Rect.width)
                : node2Rect.top - (node1Rect.top + node1Rect.height);
            setGapSize(gapSize);
            const parentPixelsMinusGap =
                (parentIsRow ? parentRectNew.width : parentRectNew.height) -
                (gapSize * parentNode.children!.length - 1);
            const newPixelToSizeRatio = totalSiblingSize / parentPixelsMinusGap;
            // console.log("newPixelToSizeRatio", newPixelToSizeRatio, siblingSize, parentPixelsMinusGap);
            setPixelToSizeRatio(newPixelToSizeRatio);
            const newCombinedNodesRect: Dimensions = {
                top: node1Rect.top,
                left: node1Rect.left,
                height: parentIsRow ? node1Rect.height : node1Rect.height + node2Rect.height + gapSize,
                width: parentIsRow ? node1Rect.width + node2Rect.width + gapSize : node1Rect.width,
            };
            setCombinedNodesRect(newCombinedNodesRect);
            // console.log(
            //     "startResize",
            //     parentNode,
            //     index,
            //     parentIsRow,
            //     gapSize,
            //     parentRectNew,
            //     node1Rect,
            //     node2Rect,
            //     newCombinedNodesRect,
            //     newPixelToSizeRatio
            // );
        }
    };

    // Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor
    const handlePointerMove = useCallback(
        throttle(10, (event: React.PointerEvent<HTMLDivElement>) => {
            if (trackingPointer === event.pointerId) {
                const { clientX, clientY } = event;
                // console.log("handlePointerMove", [clientX, clientY], combinedNodesRect, pixelToSizeRatio);
                const parentIsRow = parentNode.flexDirection === FlexDirection.Row;
                const combinedStart = parentIsRow ? combinedNodesRect.left : combinedNodesRect.top;
                const combinedEnd = parentIsRow
                    ? combinedNodesRect.left + combinedNodesRect.width
                    : combinedNodesRect.top + combinedNodesRect.height;
                const clientPoint = parentIsRow ? clientX : clientY;
                // console.log("handlePointerMove", parentNode, index, clientX, clientY, parentRect, combinedNodesRect);
                if (clientPoint > combinedStart + 10 && clientPoint < combinedEnd - 10) {
                    const halfGap = gapSize / 2;
                    const sizeNode1 = clientPoint - combinedStart - halfGap;
                    const sizeNode2 = combinedEnd - clientPoint + halfGap;
                    const resizeAction: LayoutTreeResizeNodeAction = {
                        type: LayoutTreeActionType.ResizeNode,
                        resizeOperations: [
                            {
                                nodeId: parentNode.children![index].id,
                                size: parseFloat((sizeNode1 * pixelToSizeRatio).toPrecision(5)),
                            },
                            {
                                nodeId: parentNode.children![index + 1].id,
                                size: parseFloat((sizeNode2 * pixelToSizeRatio).toPrecision(5)),
                            },
                        ],
                    };
                    const setPendingAction: LayoutTreeSetPendingAction = {
                        type: LayoutTreeActionType.SetPendingAction,
                        action: resizeAction,
                    };

                    dispatch(setPendingAction);
                }
            }
        }),
        [parentNode, trackingPointer, dispatch, gapSize, combinedNodesRect, pixelToSizeRatio, index]
    );

    // We want to use pointer capture so the operation continues even if the pointer leaves the bounds of the handle
    function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
        resizeHandleRef.current?.setPointerCapture(event.pointerId);
        startResize();
    }

    // This indicates that we're ready to start tracking the resize operation via the pointer
    function onPointerCapture(event: React.PointerEvent<HTMLDivElement>) {
        setTrackingPointer(event.pointerId);
    }

    // We want to wait a bit before committing the pending resize operation in case some events haven't arrived yet.
    const onPointerRelease = useCallback(
        debounce(30, (event: React.PointerEvent<HTMLDivElement>) => {
            setTrackingPointer(undefined);
            dispatch({ type: LayoutTreeActionType.CommitPendingAction });
        }),
        [dispatch]
    );

    // Don't render if we are dealing with the last child of a parent
    if (index + 1 >= parentNode.children!.length) {
        return;
    }

    return (
        <div
            ref={resizeHandleRef}
            className={clsx("resize-handle", `flex-${parentNode.flexDirection}`)}
            onPointerDown={onPointerDown}
            onGotPointerCapture={onPointerCapture}
            onLostPointerCapture={onPointerRelease}
            onPointerMove={handlePointerMove}
        >
            <div className="line" />
        </div>
    );
};

interface PlaceholderProps<T> {
    /**
     * The layout tree state.
     */
    layoutTreeState: LayoutTreeState<T>;
    /**
     * A reference to the div containing the overlay nodes. Used to normalize the position of the target node as the overlay container is moved in and out of view.
     */
    overlayContainerRef: React.RefObject<HTMLElement>;
    /**
     * The mapping of all layout nodes to their corresponding mounted overlay node.
     */
    nodeRefsAtom: PrimitiveAtom<NodeRefMap>;
    /**
     * Any styling to apply to the placeholder container div.
     */
    style: React.CSSProperties;
}

/**
 * An overlay to preview pending actions on the layout tree.
 */
const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps<T>) => {
    const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null);

    const nodeRefs = useAtomValue(nodeRefsAtom);

    useEffect(() => {
        let newPlaceholderOverlay: ReactNode;
        if (overlayContainerRef?.current) {
            switch (layoutTreeState?.pendingAction?.type) {
                case LayoutTreeActionType.Move: {
                    const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction<T>;
                    let parentId: string;
                    if (action.insertAtRoot) {
                        parentId = layoutTreeState.rootNode.id;
                    } else {
                        parentId = action.parentId;
                    }

                    const parentNode = findNode(layoutTreeState.rootNode, parentId);
                    if (action.index !== undefined && parentNode) {
                        const targetIndex = Math.min(
                            parentNode.children ? parentNode.children.length - 1 : 0,
                            Math.max(0, action.index - 1)
                        );
                        let targetNode = parentNode?.children?.at(targetIndex);
                        let targetRef: React.RefObject<HTMLElement>;
                        if (targetNode) {
                            targetRef = nodeRefs.get(targetNode.id);
                        } else {
                            targetRef = nodeRefs.get(parentNode.id);
                            targetNode = parentNode;
                        }
                        if (targetRef?.current) {
                            const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
                            const targetBoundingRect = targetRef.current.getBoundingClientRect();

                            // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent.
                            // Default to placing the placeholder in the first half of the target node.
                            const placeholderDimensions: Dimensions = {
                                height:
                                    parentNode.flexDirection === FlexDirection.Column
                                        ? targetBoundingRect.height / 2
                                        : targetBoundingRect.height,
                                width:
                                    parentNode.flexDirection === FlexDirection.Row
                                        ? targetBoundingRect.width / 2
                                        : targetBoundingRect.width,
                                top: targetBoundingRect.top - overlayBoundingRect.top,
                                left: targetBoundingRect.left - overlayBoundingRect.left,
                            };

                            if (action.index > targetIndex) {
                                if (action.index >= (parentNode.children?.length ?? 1)) {
                                    // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom).
                                    placeholderDimensions.top +=
                                        parentNode.flexDirection === FlexDirection.Column &&
                                        targetBoundingRect.height / 2;
                                    placeholderDimensions.left +=
                                        parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2;
                                } else {
                                    // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node
                                    placeholderDimensions.top +=
                                        parentNode.flexDirection === FlexDirection.Column &&
                                        (3 * targetBoundingRect.height) / 4;
                                    placeholderDimensions.left +=
                                        parentNode.flexDirection === FlexDirection.Row &&
                                        (3 * targetBoundingRect.width) / 4;
                                }
                            }

                            const placeholderTransform = setTransform(placeholderDimensions);
                            newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
                        }
                    }
                    break;
                }
                case LayoutTreeActionType.Swap: {
                    const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction;
                    // console.log("placeholder for swap", action);
                    const targetNodeId = action.node1Id;
                    const targetRef = nodeRefs.get(targetNodeId);
                    if (targetRef?.current) {
                        const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
                        const targetBoundingRect = targetRef.current.getBoundingClientRect();
                        const placeholderDimensions: Dimensions = {
                            top: targetBoundingRect.top - overlayBoundingRect.top,
                            left: targetBoundingRect.left - overlayBoundingRect.left,
                            height: targetBoundingRect.height,
                            width: targetBoundingRect.width,
                        };

                        const placeholderTransform = setTransform(placeholderDimensions);
                        newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
                    }
                    break;
                }
                default:
                    // No-op
                    break;
            }
        }
        setPlaceholderOverlay(newPlaceholderOverlay);
    }, [layoutTreeState.pendingAction, nodeRefs, overlayContainerRef]);

    return (
        <div className="placeholder-container" style={style}>
            {placeholderOverlay}
        </div>
    );
};
