import { calculateScaleFactors, invertPoint } from "./style";

/**
 * Creates a render function that adds drag-and-drop and click functionality to child elements of a plot.
 *
 * This function enhances the rendering of plot elements by adding interactive behaviors
 * such as dragging, clicking, and tracking position changes. It's designed to work with
 * Observable Plot's rendering pipeline.
 *
 * @param {Object} options - Configuration options for the child events.
 * @param {Function} [options.onDragStart] - Callback function called when dragging starts.
 * @param {Function} [options.onDrag] - Callback function called during dragging.
 * @param {Function} [options.onDragEnd] - Callback function called when dragging ends.
 * @param {Function} [options.onClick] - Callback function called when a child element is clicked.
 * @param {Function} [options.onMouseEnter] - Callback function called when mouse enters a plot element.
 * @param {Function} [options.onMouseLeave] - Callback function called when mouse leaves a plot element.
 * @returns {Function} A render function to be used in the Observable Plot rendering pipeline.
 *
 */

export function renderChildEvents({
  onDragStart,
  onDrag,
  onDragEnd,
  onClick,
  onMouseEnter,
  onMouseLeave,
}) {
  function render(index, scales, values, dimensions, context, next) {
    // Call the next render function to get the base SVG group
    const g = next(index, scales, values, dimensions, context);
    let activeElement = null;
    let totalDx = 0;
    let totalDy = 0;
    let initialUnscaledX, initialUnscaledY;
    let initialIndex;
    let initialTransform = "";
    const dragThreshold = 2; // pixels
    let clickStartTime;
    let scaleX, scaleY;
    let scaleFactors;
    let isDragging = false;
    // Store initial event modifiers when drag starts
    let dragModifiers = null;

    // Create empty local objects for scaled and unscaled values.
    // These are used to track the current positions of children that
    // have been dragged, without mutating the original values.
    const localScaledValues = {
      x: {},
      y: {},
    };
    const localUnscaledValues = {
      x: {},
      y: {},
    };

    // Calculate scale factors to account for differences between
    // SVG logical dimensions and actual rendered size
    const updateScaleFactors = () => {
      const svg = g.ownerSVGElement;
      scaleFactors = calculateScaleFactors(svg);
    };

    // Helper function to create a payload for callbacks
    // This includes both scaled (pixel) and unscaled (data) coordinates
    const createPayload = (index, unscaledX, unscaledY, type, event) => ({
      index,
      x: unscaledX,
      y: unscaledY,
      pixels: {
        x: scales.x(unscaledX) * scaleX,
        y: scales.y(unscaledY) * scaleY,
      },
      type,
      // Use stored modifiers for drag events, current event modifiers otherwise
      altKey: type.startsWith("drag") ? dragModifiers.altKey : event.altKey,
      ctrlKey: type.startsWith("drag") ? dragModifiers.ctrlKey : event.ctrlKey,
      metaKey: type.startsWith("drag") ? dragModifiers.metaKey : event.metaKey,
      shiftKey: type.startsWith("drag")
        ? dragModifiers.shiftKey
        : event.shiftKey,
    });

    // Helper function to parse existing SVG transforms
    // This allows us to combine our offsets with any existing transforms
    const parseTransform = (transform) => {
      const match = transform.match(
        /translate\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)/,
      );
      return match
        ? { x: parseFloat(match[1]), y: parseFloat(match[2]) }
        : { x: 0, y: 0 };
    };

    const findDirectChild = (element) => {
      while (element && element.parentNode !== g) {
        element = element.parentNode;
      }
      return element;
    };

    const handleDragStart = (event) => {
      // Find the first element for which g is the direct parent
      activeElement = findDirectChild(event.target);
      if (!activeElement) return;

      event.preventDefault();
      updateScaleFactors();
      initialIndex = Array.from(g.children).indexOf(activeElement);
      // Use local values if available, otherwise fall back to original values
      initialUnscaledX =
        localUnscaledValues.x[initialIndex] ??
        values.channels.x.value[initialIndex];
      initialUnscaledY =
        localUnscaledValues.y[initialIndex] ??
        values.channels.y.value[initialIndex];
      initialTransform = activeElement.getAttribute("transform") || "";

      // Store initial event modifiers
      dragModifiers = {
        altKey: event.altKey,
        ctrlKey: event.ctrlKey,
        metaKey: event.metaKey,
        shiftKey: event.shiftKey,
      };

      clickStartTime = new Date().getTime();

      document.addEventListener("mousemove", handleDrag);
      document.addEventListener("mouseup", handleDragEnd);
    };

    const handleDrag = (event) => {
      if (!activeElement) return;

      totalDx += event.movementX / scaleFactors.x;
      totalDy += event.movementY / scaleFactors.y;

      if (
        !isDragging &&
        (Math.abs(totalDx) > dragThreshold || Math.abs(totalDy) > dragThreshold)
      ) {
        isDragging = true;
        if (onDragStart)
          onDragStart(
            createPayload(
              initialIndex,
              initialUnscaledX,
              initialUnscaledY,
              "dragstart",
              event,
            ),
          );
      }

      if (isDragging) {
        // Use the scales' invert function to convert pixel offsets back to data coordinates
        const currentUnscaledX = scales.x.invert(
          scales.x(initialUnscaledX) + totalDx,
        );
        const currentUnscaledY = scales.y.invert(
          scales.y(initialUnscaledY) + totalDy,
        );

        const initialTranslate = parseTransform(initialTransform);
        const newTranslateX = initialTranslate.x + totalDx * scaleX;
        const newTranslateY = initialTranslate.y + totalDy * scaleY;

        if (onDrag)
          onDrag(
            createPayload(
              initialIndex,
              currentUnscaledX,
              currentUnscaledY,
              "drag",
              event,
            ),
          );

        // Update the SVG transform to move the element
        activeElement.setAttribute(
          "transform",
          `translate(${newTranslateX}, ${newTranslateY})`,
        );
      }
    };

    const handleDragEnd = (event) => {
      if (!activeElement) return;

      const clickEndTime = new Date().getTime();
      const clickDuration = clickEndTime - clickStartTime;

      if (isDragging) {
        // Calculate final positions in both unscaled (data) and scaled (pixel) coordinates
        const finalUnscaledX = scales.x.invert(
          scales.x(initialUnscaledX) + totalDx,
        );
        const finalUnscaledY = scales.y.invert(
          scales.y(initialUnscaledY) + totalDy,
        );

        // Update local values to reflect the new position
        localUnscaledValues.x[initialIndex] = finalUnscaledX;
        localUnscaledValues.y[initialIndex] = finalUnscaledY;
        localScaledValues.x[initialIndex] = finalUnscaledX;
        localScaledValues.y[initialIndex] = finalUnscaledY;

        const initialTranslate = parseTransform(initialTransform);
        const finalTranslateX = initialTranslate.x + totalDx * scaleX;
        const finalTranslateY = initialTranslate.y + totalDy * scaleY;

        if (onDragEnd)
          onDragEnd(
            createPayload(
              initialIndex,
              finalUnscaledX,
              finalUnscaledY,
              "dragend",
              event,
            ),
          );

        // Set the final transform on the SVG element
        activeElement.setAttribute(
          "transform",
          `translate(${finalTranslateX}, ${finalTranslateY})`,
        );
      } else if (
        clickDuration < 200 &&
        Math.abs(totalDx) < dragThreshold &&
        Math.abs(totalDy) < dragThreshold
      ) {
        // If it's a short click and hasn't moved much, treat it as a click
        if (onClick)
          onClick(
            createPayload(
              initialIndex,
              initialUnscaledX,
              initialUnscaledY,
              "click",
              event,
            ),
          );
      }

      // Clean up event listeners
      document.removeEventListener("mousemove", handleDrag);
      document.removeEventListener("mouseup", handleDragEnd);

      // Reset state
      activeElement = null;
      totalDx = 0;
      totalDy = 0;
      isDragging = false;
      dragModifiers = null;
    };

    const handleMouseEnter = (event) => {
      if (isDragging || !onMouseEnter) return;
      const hoveredElement =
        event.target.parentNode === g ? event.target : null;
      if (!hoveredElement) return;

      const index = Array.from(g.children).indexOf(hoveredElement);
      const unscaledX = values.channels.x.value[index];
      const unscaledY = values.channels.y.value[index];

      onMouseEnter(
        createPayload(index, unscaledX, unscaledY, "mouseenter", event),
      );
    };

    const handleMouseLeave = (event) => {
      if (!onMouseLeave) return;
      const hoveredElement =
        event.target.parentNode === g && g.parentNode ? event.target : null;
      if (!hoveredElement) return;

      const index = Array.from(g.children).indexOf(hoveredElement);
      const unscaledX = values.channels.x.value[index];
      const unscaledY = values.channels.y.value[index];

      if (onMouseLeave)
        onMouseLeave(
          createPayload(index, unscaledX, unscaledY, "mouseleave", event),
        );
    };

    // Add event listeners to the SVG group

    g.addEventListener("mousedown", handleDragStart);
    g.addEventListener("mouseout", handleMouseLeave, true);

    // add `mouseenter` listener after `g` has been added to the DOM
    // (otherwise it is called during drag events)
    setTimeout(() => {
      g.addEventListener("mouseenter", handleMouseEnter, true);
    }, 100);

    return g;
  }

  return render;
}
