import React, {
  useState, useRef, useEffect, useLayoutEffect,
} from 'react';
import PropTypes from 'prop-types';
import { MapInteractionContext } from './MapInteractionContext';
import { MapInteractionControls } from './MapInteractionControls';

const propTypes = {
  children: PropTypes.node.isRequired,
  transform: PropTypes.shape({
    scale: PropTypes.number,
    x: PropTypes.number,
    y: PropTypes.number,
  }),
  onChange: PropTypes.func,
  minScale: PropTypes.number,
  maxScale: PropTypes.number,
  showControls: PropTypes.bool,
  isDragBlocked: PropTypes.bool,
  /**
    *  Disable drag on map on specific element (CSS selector, e.g. ".my-element")
    */
  preventDragSelector: PropTypes.string,
  preventScrollSelector: PropTypes.string,
};

const defaultProps = {
  minScale: 0.2,
  maxScale: 1,
  transform: { scale: 1, x: 20, y: 20 },
  showControls: false,
  onChange: () => {},
  isDragBlocked: false,
  preventDragSelector: '',
  preventScrollSelector: '',
};

const isTouch = (e) => e.touches && e.touches[0];
const isDualTouch = (e) => e.touches && e.touches.length === 2;

const getPointerPosition = (e) => {
  const pointerX = isTouch(e) ? e.touches[0].clientX : e.clientX;
  const pointerY = isTouch(e) ? e.touches[0].clientY : e.clientY;

  return { x: pointerX, y: pointerY };
};

const getPointerDelta = (e, from = { x: 0, y: 0 }) => {
  const pointer = getPointerPosition(e);
  const deltaX = pointer.x - from.x;
  const deltaY = pointer.y - from.y;

  return { x: deltaX, y: deltaY };
};

const scaleFromDeltaTranslation = ({ scale, delta, transform }) => {
  const scaleRatio = scale / (transform.scale || 1);

  const vector = {
    x: (scaleRatio * delta.x) - delta.x,
    y: (scaleRatio * delta.y) - delta.y,
  };

  const newTranslation = {
    x: transform.x - vector.x,
    y: transform.y - vector.y,
  };

  return {
    ...newTranslation,
    scale,
  };
};

const getDeltaFromEventOriginToTranslationOrigin = ({ origin, translation, containerNode }) => {
  const containerOffset = containerNode.getBoundingClientRect();
  const translationOrigin = {
    x: containerOffset.left + translation.x,
    y: containerOffset.top + translation.y,
  };

  return {
    x: origin.x - translationOrigin.x,
    y: origin.y - translationOrigin.y,
  };
};

const getPinchScaleDelta = (prevTouches, currTouches) => {
  const [A, B] = prevTouches;
  const [C, D] = currTouches;

  const diff1 = {
    x: getPointerPosition(B).x - getPointerPosition(A).x,
    y: getPointerPosition(B).y - getPointerPosition(A).y,
  };

  const diff2 = {
    x: getPointerPosition(D).x - getPointerPosition(C).x,
    y: getPointerPosition(D).y - getPointerPosition(C).y,
  };

  const distance1 = Math.sqrt((diff1.x ** 2) + (diff1.y ** 2));
  const distance2 = Math.sqrt((diff2.x ** 2) + (diff2.y ** 2));

  if (!distance1 || !distance2) {
    return 1;
  }

  const scaleDelta = distance1 / distance2;

  return scaleDelta;
};

const getPinchMidPoint = (touches) => {
  const [A, B] = touches;

  const midPoint = {
    x: (getPointerPosition(A).x + getPointerPosition(B).x) / 2,
    y: (getPointerPosition(A).y + getPointerPosition(B).y) / 2,
  };

  return midPoint;
};

export const MapInteraction = React.forwardRef(({
  children,
  transform: transformProp,
  onChange,
  minScale,
  maxScale,
  showControls,
  isDragBlocked,
  preventDragSelector,
  preventScrollSelector,
}, ref) => {
  const containerNodeRef = useRef();
  const contentNodeRef = useRef();
  const preventDragSelectorRef = useRef();
  preventDragSelectorRef.current = preventDragSelector;
  const preventScrollSelectorRef = useRef();
  preventScrollSelectorRef.current = preventScrollSelector;

  const containerWidth = containerNodeRef.current ? containerNodeRef.current.clientWidth : 1000;
  const contentWidth = contentNodeRef.current ? contentNodeRef.current.clientWidth : 1000;

  const containerHeight = containerNodeRef.current ? containerNodeRef.current.clientHeight : 1000;
  const contentHeight = contentNodeRef.current ? contentNodeRef.current.clientHeight : 1000;

  const getTransformInRange = ({ scale, x, y }) => {
    const newScale = Math.max(minScale, Math.min(scale, maxScale));

    return {
      scale: newScale,
      x: Math.max(
        contentWidth * newScale * -1,
        Math.min(x, containerWidth)
      ),
      y: Math.max(
        contentHeight * newScale * -1,
        Math.min(y, containerHeight)
      ),
    };
  };

  const [transformState, setTransformState] = useState(
    getTransformInRange(transformProp)
  );

  useLayoutEffect(() => {
    setTransformState(getTransformInRange({
      x: transformProp.x,
      y: transformProp.y,
      scale: transformProp.scale,
    }));
  }, [transformProp.x, transformProp.y, transformProp.scale]);

  useLayoutEffect(() => {
    onChange(transformState);
  }, [transformState]);

  const startPointerPositionRef = useRef();
  const startTouchesRef = useRef(null);
  const [isDragging, setIsDragging] = useState(false);
  const [isScrolling, setIsScrolling] = useState(false);
  const [isPinching, setIsPinching] = useState(false);

  useEffect(() => {
    const timeout = setTimeout(() => {
      setIsScrolling(false);
    }, 100);

    return () => clearTimeout(timeout);
  }, [isScrolling]);

  const handleStartDrag = (e) => {
    if (isDragBlocked) {
      return;
    }

    if (preventDragSelectorRef.current && e.target.closest(preventDragSelectorRef.current)) {
      return;
    }

    e.preventDefault();
    setIsDragging(true);
    startPointerPositionRef.current = getPointerPosition(e);
  };

  const handleDrag = (e) => {
    e.preventDefault();
    if (isDualTouch(e)) {
      return;
    }
    const delta = getPointerDelta(e, startPointerPositionRef.current);
    startPointerPositionRef.current = getPointerPosition(e);

    setTransformState(({ x, y, scale }) => getTransformInRange({
      x: x + delta.x,
      y: y + delta.y,
      scale,
    }));
  };

  const handleStopDrag = () => {
    setIsDragging(false);
  };

  const scaleFromOrigin = (newScaleDelta, origin, prevTransform) => {
    const newScale = prevTransform.scale + (1 - newScaleDelta);
    const deltaTranslation = getDeltaFromEventOriginToTranslationOrigin({
      origin,
      translation: {
        x: prevTransform.x,
        y: prevTransform.y,
      },
      containerNode: containerNodeRef.current,
    });

    return scaleFromDeltaTranslation({
      scale: getTransformInRange({ ...transformState, scale: newScale }).scale,
      delta: deltaTranslation,
      transform: prevTransform,
    });
  };

  const handleScrollScale = (e) => {
    if (preventScrollSelectorRef.current && e.target.closest(preventScrollSelectorRef.current)) {
      return;
    }

    e.preventDefault();

    setIsScrolling(true);

    const pointerPosition = getPointerPosition(e);

    setTransformState((prevTransform) => getTransformInRange(
      scaleFromOrigin(2 ** (e.deltaY * 0.002), pointerPosition, prevTransform)
    ));
  };

  const handlePinchStart = (e) => {
    if (preventScrollSelectorRef.current && e.target.closest(preventScrollSelectorRef.current)) {
      return;
    }

    if (!isDualTouch(e)) {
      return;
    }

    e.preventDefault();

    startTouchesRef.current = e.touches;
    setIsPinching(true);
  };

  const handlePinchEnd = (e) => {
    e.preventDefault();

    startTouchesRef.current = null;
    setIsPinching(false);
  };

  const handlePinch = (e) => {
    if (!isDualTouch(e) || !startTouchesRef.current || startTouchesRef.current.length !== 2) {
      return;
    }

    e.preventDefault();

    const scaleDelta = getPinchScaleDelta(startTouchesRef.current, e.touches);
    const midPoint = getPinchMidPoint(e.touches);

    setTransformState((prevTransform) => getTransformInRange(
      scaleFromOrigin(scaleDelta, midPoint, prevTransform)
    ));

    startTouchesRef.current = e.touches;
  };

  const handleScale = (scale) => {
    setTransformState((s) => getTransformInRange({ ...s, scale }));
  };

  const handleFitContent = () => {
    const ratioX = (containerWidth / contentWidth);
    const ratioY = (containerHeight / contentHeight);
    const newScale = ratioX < ratioY ? ratioX : ratioY;

    setTransformState(getTransformInRange({
      x: (containerWidth - (contentWidth * newScale)) / 2,
      y: (containerHeight - (contentHeight * newScale)) / 2,
      scale: newScale,
    }));
  };

  const addEvent = (eventName, callback, node = containerNodeRef.current) => {
    node.addEventListener(eventName, callback, { passive: false });
  };

  const removeEvent = (eventName, callback, node = containerNodeRef.current) => {
    node.removeEventListener(eventName, callback, { passive: false });
  };

  // Scroll events
  useEffect(() => {
    if (!containerNodeRef.current) {
      return () => {};
    }

    addEvent('mousewheel', handleScrollScale);
    addEvent('wheel', handleScrollScale);

    return () => {
      removeEvent('mousewheel', handleScrollScale);
      removeEvent('wheel', handleScrollScale);
    };
  }, [containerNodeRef.current]);

  // On Pinch Start events
  useEffect(() => {
    if (!containerNodeRef.current) {
      return () => {};
    }

    addEvent('touchstart', handlePinchStart);

    return () => {
      removeEvent('touchstart', handlePinchStart);
    };
  }, [containerNodeRef.current]);

  // On Pinch events
  useEffect(() => {
    if (!containerNodeRef.current || !isPinching) {
      return () => {};
    }

    addEvent('touchmove', handlePinch);
    addEvent('touchend', handlePinchEnd, window);

    return () => {
      removeEvent('touchmove', handlePinch);
      removeEvent('touchend', handlePinchEnd, window);
    };
  }, [containerNodeRef.current, isPinching]);

  // Start Drag events
  useEffect(() => {
    if (!containerNodeRef.current) {
      return () => {};
    }

    addEvent('mousedown', handleStartDrag);
    addEvent('touchstart', handleStartDrag);

    return () => {
      removeEvent('mousedown', handleStartDrag);
      removeEvent('touchstart', handleStartDrag);
    };
  }, [containerNodeRef.current]);

  // On Drag events
  useEffect(() => {
    if (!containerNodeRef.current || !isDragging) {
      return () => {};
    }

    addEvent('touchmove', handleDrag);
    addEvent('mousemove', handleDrag);
    addEvent('mouseup', handleStopDrag, window);
    addEvent('touchend', handleStopDrag, window);

    return () => {
      removeEvent('touchmove', handleDrag);
      removeEvent('mousemove', handleDrag);
      removeEvent('mouseup', handleStopDrag, window);
      removeEvent('touchend', handleStopDrag, window);
    };
  }, [containerNodeRef.current, isDragging]);

  return (
    <MapInteractionContext.Provider
      value={{
        scale: transformState.scale,
        isDragging,
      }}
    >
      <div
        className={`sui-prj-map-interaction ${isDragging ? 'as--dragging' : ''}`}
        ref={ref}
      >
        <div
          ref={containerNodeRef}
          className="sui-prj-map-interaction__field"
        >
          <div
            className="sui-prj-map-interaction__board"
            style={{
              transform: `translate(${transformState.x}px, ${transformState.y}px) scale(${transformState.scale})`,
              transition: !(isDragging || isScrolling || isPinching) ? '0.2s' : null,
            }}
          >
            <div className="sui-prj-map-interaction__layer" ref={contentNodeRef}>
              {children}
            </div>
            <div className={`sui-prj-map-interaction__background ${isDragging ? 'as--dragging' : ''}`} />
          </div>
        </div>
        {showControls && (
          <MapInteractionControls
            scale={transformState.scale}
            minScale={minScale}
            maxScale={maxScale}
            onScale={handleScale}
            onFitScale={handleFitContent}
          />
        )}
      </div>
    </MapInteractionContext.Provider>
  );
});

export default MapInteraction;

MapInteraction.propTypes = propTypes;
MapInteraction.defaultProps = defaultProps;
