import clsx from 'clsx';
import { produce } from 'immer';
import React, { useEffect, useMemo, useRef } from 'react';
import { twMerge } from 'tailwind-merge';

import { Line, Point, PointType } from './colorScaleUtils';

export type BezierDisplayProps = {
  className?: string;
  pathClassName?: string;
  controlLineClassName?: string;
  controlPointClassName?: string;
  anchorPointClassName?: string;
  points: Point[];
  showAnchorPoints?: boolean;
  showControlPoints?: boolean;
  scaleX?: number;
  scaleY?: number;
  viewBoxPaddingX?: number;
  viewBoxPaddingY?: number;
};

export type Props = Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> &
  BezierDisplayProps & {
    innerClassName?: string;
    onChange?: (
      points: Point[],
      options: { prevPoints: Point[]; index: number; changeMode: number }
    ) => void;
  };

export enum BezierEditorChangeMode {
  /**
   * Constrains min/max values of points
   */
  ConstrainMinMax = 0b1,
  /**
   * Prevents invalid crossover between points
   */
  PreventOverlap = 0b10,
  /**
   * When an anchor is moved, its control points move with it
   */
  AnchorControlSync = 0b100,
  /**
   * Modifying a control point will move the sibling control point to preserve
   * a straight line through their shared anchor point
   */
  ControlSymmetric = 0b1000,
  /**
   * Modifying a control point will move the sibling control point to preserve
   * the relative distance from their shared anchor point
   */
  ControlRelativeScale = 0b10000,
}

function formatPoint(point: Point, scaleX = 1, scaleY = 1) {
  return `${scaleX * point.x} ${scaleY * (1 - point.y)}`;
}

function getPathData(points: Point[], scaleX = 1, scaleY = 1) {
  if (!points || points.length < 2) return '';

  // Start with a move to the first point
  const pathParts = [`M ${formatPoint(points[0], scaleX, scaleY)}`];

  // Process points in groups to form Bezier curve segments
  for (let i = 3; i < points.length; i += 3) {
    pathParts.push(
      [
        `C ${formatPoint(points[i - 2], scaleX, scaleY)}`, // left control point
        `${formatPoint(points[i - 1], scaleX, scaleY)}`, // right control point
        `${formatPoint(points[i], scaleX, scaleY)}`, // anchor
      ].join(', ')
    );
  }

  return pathParts.join(' ');
}

const BezierCurvePreview: React.FC<
  Omit<React.SVGAttributes<SVGElement>, keyof BezierDisplayProps> &
    BezierDisplayProps
> = (props) => {
  const {
    className,
    pathClassName,
    controlLineClassName,
    controlPointClassName,
    anchorPointClassName,
    points,
    showControlPoints = true,
    showAnchorPoints = true,
    scaleX = 100,
    scaleY = scaleX,
    viewBoxPaddingX = 0,
    viewBoxPaddingY = viewBoxPaddingX,
    ...restProps
  } = props;

  const controlLines = useMemo(() => {
    const result: Array<Line> = [];
    for (let i = 0; i < points.length; i += 3) {
      // left
      if (i - 1 >= 0) {
        const p1 = points[i]; // anchor point side
        const p2 = points[i - 1]; // control point side
        result.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y });
      }
      // right
      if (i + 1 < points.length) {
        const p1 = points[i]; // anchor point side
        const p2 = points[i + 1]; // control point side
        result.push({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y });
      }
    }
    return result;
  }, [points]);

  const viewBox = [
    0 - viewBoxPaddingX,
    0 - viewBoxPaddingY,
    scaleX + viewBoxPaddingX + viewBoxPaddingX,
    scaleY + viewBoxPaddingY + viewBoxPaddingY,
  ].join(' ');

  return (
    <svg
      viewBox={viewBox}
      width={100}
      height={100}
      className={twMerge(
        'bg-background-primary text-foreground-primary',
        className
      )}
      {...restProps}
    >
      {/* Curve */}
      <path
        className={twMerge('stroke-1 text-current', pathClassName)}
        d={getPathData(points, scaleX, scaleY)}
        fill='none'
        stroke='currentColor'
        vectorEffect='non-scaling-stroke'
      />

      {/* Control lines */}
      {showControlPoints && (
        <g fill='none' stroke='currentColor'>
          {controlLines.map((line, i) => (
            <line
              key={`control-line-${i}`}
              className={twMerge(
                'stroke-1 text-foreground-tertiary-glass',
                controlLineClassName
              )}
              x1={scaleX * line.x1}
              y1={scaleY * (1 - line.y1)}
              x2={scaleX * line.x2}
              y2={scaleY * (1 - line.y2)}
            />
          ))}
        </g>
      )}

      {/* Anchor points */}
      {showAnchorPoints && (
        <g fill='currentColor' stroke='none'>
          {points.map((point, i) =>
            i % 3 === 0 ? (
              <circle
                key={`anchor-${i}`}
                className={twMerge(
                  'fill-background-primary stroke-current stroke-2 [r:3]',
                  anchorPointClassName
                )}
                cx={scaleX * point.x}
                cy={scaleY * (1 - point.y)}
              />
            ) : null
          )}
        </g>
      )}

      {/* Control points */}
      {showControlPoints && (
        <g fill='currentColor' stroke='none'>
          {points.map((point, i) =>
            i % 3 === 0 ? null : (
              <rect
                key={`control-${i}`}
                className={twMerge(
                  '[transform:translate(-0.5em,-0.5em)] text-[4px] text-foreground-tertiary [rx:1] [ry:1]',
                  controlPointClassName
                )}
                x={scaleX * point.x}
                y={scaleY * (1 - point.y)}
                width='1em'
                height='1em'
              />
            )
          )}
        </g>
      )}
    </svg>
  );
};

const BezierCurveEditor: React.FC<Props> = (props) => {
  const {
    className,
    innerClassName,
    points,
    onChange,
    showAnchorPoints = true,
    showControlPoints = true,
    pathClassName,
    controlLineClassName,
    controlPointClassName,
    anchorPointClassName,
    scaleX = 100,
    scaleY = scaleX,
    viewBoxPaddingX = 10,
    viewBoxPaddingY = viewBoxPaddingX,
    ...restProps
  } = props;

  const buttonContainerRef = useRef<HTMLDivElement>(null);
  const stateRef = useRef<{
    points: Point[];
    prevPoints?: Point[];
    dragIndex: number | null;
    dragX?: number;
    dragY?: number;
  }>({ points, dragIndex: null });
  useEffect(() => {
    stateRef.current.points = points;
  }, [points]);

  const eventHandlers = useMemo(() => {
    function handleMouseDown(e: React.MouseEvent<HTMLButtonElement>) {
      // Which point did we click on?
      const index =
        e.currentTarget.dataset.pointIndex != null
          ? parseInt(e.currentTarget.dataset.pointIndex, 10)
          : null;

      // Invalid point
      if (index == null) return;

      // Special behavior if we click with the alt key
      if (e.altKey && index % 3 === PointType.Anchor) {
        e.preventDefault();
        const prevPoints = stateRef.current.points;

        if (index == null || !buttonContainerRef.current) return;

        const updatedPoints = produce(prevPoints, (nextPoints) => {
          const point = nextPoints[index];
          if (index - 1 >= 0) {
            nextPoints[index - 1].x = point.x;
            nextPoints[index - 1].y = point.y;
          }
          if (index + 1 < nextPoints.length) {
            nextPoints[index + 1].x = point.x;
            nextPoints[index + 1].y = point.y;
          }
        });

        const changeMode =
          BezierEditorChangeMode.ConstrainMinMax |
          BezierEditorChangeMode.PreventOverlap |
          BezierEditorChangeMode.AnchorControlSync;

        onChange?.(updatedPoints, { prevPoints, index, changeMode });
        return;
      }

      stateRef.current.dragIndex = index;
      stateRef.current.dragX = e.clientX;
      stateRef.current.dragY = e.clientY;
      stateRef.current.prevPoints = stateRef.current.points;

      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    }

    function handleMouseMove(e: MouseEvent) {
      const { prevPoints = stateRef.current.points, dragIndex: index } =
        stateRef.current;

      if (index == null || !buttonContainerRef.current) return;

      // Get target position relative to the bounding box
      const rect = buttonContainerRef.current.getBoundingClientRect();
      const x = (e.clientX - rect.left) / rect.width;
      const y = (e.clientY - rect.top) / rect.height;

      const updatedPoints = produce(prevPoints, (nextPoints) => {
        nextPoints[index].x = x;
        nextPoints[index].y = 1 - y;
      });

      let changeMode =
        BezierEditorChangeMode.ConstrainMinMax |
        BezierEditorChangeMode.PreventOverlap |
        BezierEditorChangeMode.AnchorControlSync;

      switch (true) {
        // Corner point, but still preserve relative scale...?
        case e.altKey && !e.shiftKey:
          changeMode |= BezierEditorChangeMode.ControlRelativeScale;
          break;
        // Smooth point and preserve relative scale
        case !e.altKey && e.shiftKey:
          changeMode |=
            BezierEditorChangeMode.ControlSymmetric |
            BezierEditorChangeMode.ControlRelativeScale;
          break;
        // Smooth point without preserving relative scale
        default:
          changeMode |= BezierEditorChangeMode.ControlSymmetric;
          break;
      }

      // Call the onChange handler with the updated points
      onChange?.(updatedPoints, { prevPoints, index, changeMode });
    }

    function handleMouseUp(e: MouseEvent) {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
      stateRef.current.dragIndex = null;
      stateRef.current.dragX = e.clientX;
      stateRef.current.dragY = e.clientY;
    }

    return {
      handleMouseDown,
      handleMouseMove,
      handleMouseUp,
    };
  }, [onChange]);

  // Make sure we clean up event listeners
  useEffect(() => {
    return () => {
      document.removeEventListener('mousemove', eventHandlers.handleMouseMove);
      document.removeEventListener('mouseup', eventHandlers.handleMouseUp);
    };
  }, [eventHandlers]);

  return (
    <div
      className={twMerge(
        'bg-background-primary text-foreground-primary',
        className
      )}
      {...restProps}
    >
      <div
        className={twMerge(
          'relative aspect-square min-w-[100px] rounded-sm',
          'after:pointer-events-none after:absolute after:inset-0',
          'after:rounded-[inherit] after:border after:border-border-primary',
          innerClassName
        )}
      >
        <BezierCurvePreview
          className='absolute inset-0 h-full w-full rounded-[inherit]'
          pathClassName={pathClassName}
          controlLineClassName={controlLineClassName}
          controlPointClassName={controlPointClassName}
          anchorPointClassName={anchorPointClassName}
          points={points}
          showAnchorPoints={showAnchorPoints}
          showControlPoints={showControlPoints}
          scaleX={scaleX}
          scaleY={scaleY}
          viewBoxPaddingX={viewBoxPaddingX}
          viewBoxPaddingY={viewBoxPaddingY}
        />
        <div
          className='absolute inset-x-(--viewbox-px,0) inset-y-(--viewbox-py,0)'
          style={
            {
              '--viewbox-px': `${(100 * viewBoxPaddingX) / (scaleX + 2 * viewBoxPaddingX)}%`,
              '--viewbox-py': `${(100 * viewBoxPaddingY) / (scaleY + 2 * viewBoxPaddingY)}%`,
            } as React.CSSProperties
          }
          ref={buttonContainerRef}
        >
          {points.map((point, i) =>
            (showAnchorPoints && i % 3 === PointType.Anchor) ||
            (showControlPoints && i % 3 !== PointType.Anchor) ? (
              <button
                key={`anchor-${i}`}
                className={clsx(
                  'absolute h-3 w-3 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full',
                  'transition-colors duration-75 hover:bg-overlay-on-primary',
                  {
                    'z-10 h-3 w-3': i % 3 === PointType.Anchor,
                    'z-20 h-2 w-2': i % 3 !== PointType.Anchor,
                  }
                )}
                style={{
                  left: `${100 * point.x}%`,
                  bottom: `${100 * point.y}%`,
                }}
                data-point-index={i}
                onMouseDown={eventHandlers.handleMouseDown}
              />
            ) : null
          )}
        </div>
      </div>
    </div>
  );
};

export default BezierCurveEditor;
