import { useRef, useState, useEffect, useMemo, useCallback } from 'react';
import { select } from 'd3-selection';
import { zoom as d3Zoom, zoomIdentity } from 'd3-zoom';
import useResizeObserver from './useResizeObserver';
import { NATIONAL_SIZE } from '../sizes';
import useControlSwitchError from './useControlSwitchError';

/**
 * Hook to set up zoom/pan interaction plumbing for an SVG canvas
 *
 * @typedef {import('./useZoomContext.js').Zoom} Zoom
 *
 * @param {Object} opts
 * @param {boolean} [opts.zoomable=true]
 *        A switch to disable zooming interaction listeners from being attached
 * @param {number[]} [opts.size=[960,500]]
 *        The `[width,height]` of the SVG canvas, used for the panning x-limit
 * @param {number} [opts.scaleMax=8]
 *        The maximum amount we allow users to scale by when zooming in
 * @param {number} [opts.zoomBy=2]
 *        The zoom step for programmatic zooming functions
 * @param {Object} _controlledZoomValue
 *        For internal use. A pre-existing zoom value. Do not provide this
 *        value if you are calling the hook directly.
 * @returns {Zoom}
 */
const useZoom = function useZoom(opts = {}, _controlledZoomValue) {
  const {
    zoomable = true,
    size: [width, height] = NATIONAL_SIZE,
    viewExtent: optsViewExtent,
    scaleMax = 8,
    zoomBy = 2
  } = opts;
  const [[viewTopLeftX, viewTopLeftY], [viewBottomRightX, viewBottomRightY]] = optsViewExtent || [[0, 0], [width, height]];

  // Throw an async error if control switches in our lifespan. We're controlled
  // if _controlledZoomValue is provided
  useControlSwitchError(!!_controlledZoomValue);

  // This hook is only enabled if we don't already have a controlled zoom value
  // and the zoomable option was not set to `false`
  const enabled = !_controlledZoomValue && zoomable;

  // Set up refs for the D3 selection of the SVG node and the D3 zoom object
  // itself.
  const canvasSelection = useRef();
  const zoom = useRef();

  // Set up a ref and a ref callback function for the SVG DOM node along with
  // a dummy piece of state to change so that we can update our event listeners
  // when the canvas ref gets attached to a new DOM node.
  const [containerRef, [canvasPixelWidth, canvasPixelHeight]] = useResizeObserver();
  const canvasRef = useRef();
  const [canvasRefChanged, setCanvasRefChanged] = useState({});
  const canvasRefCallback = useCallback(node => {
    canvasRef.current = node;
    // update state to a new object to force a rerender
    setCanvasRefChanged(() => ({}));
  }, []);

  // Set up state to store the current zoom/pan transform
  const [transform, setTransform] = useState({
    x: 0,
    y: 0,
    k: 1
  });

  // Set up state to store whether user needs instructions
  // on how to zoom
  const [instructionOverlay, setInstructionOverlay] = useState(null);
  const clearInstructionOverlay = useCallback(() => {
    setInstructionOverlay(null);
  }, [setInstructionOverlay]);

  // Once the component mounts, set up a zoom event handler to update the
  // transform and set up / attach a zoom handler for the canvas
  useEffect(() => {
    if (!enabled) return undefined;
    const zoomed = zoomEvent => {
      setTransform(zoomEvent.transform);
    };
    zoom.current = d3Zoom().scaleExtent([1, scaleMax]).translateExtent([[0, 0], [width, height]]).on('zoom', zoomed).filter(event => {
      // pinch-to-zoom on most browsers
      if (event.type === 'wheel' && event.ctrlKey) {
        setInstructionOverlay(null);
        return true;
      }

      // scroll zoom with meta key
      if (event.type === 'wheel' && event.metaKey) {
        setInstructionOverlay(null);
        return true;
      }

      // click-and-drag panning
      if (event.type === 'mousedown') {
        setInstructionOverlay(null);
        return true;
      }

      // two-finger touch
      if (event.touches && event.touches.length > 1) {
        setInstructionOverlay(null);
        return true;
      }

      // single-finger touch
      if (event.touches && event.touches.length < 2) {
        setInstructionOverlay('mobile');
        return false;
      }

      // fallback for other events to block
      setInstructionOverlay('desktop');
      return false;
    });
    canvasSelection.current = select(canvasRef.current);
    canvasSelection.current.call(zoom.current);
    return () => {
      // Reset zoom and then turn off zoom event handling
      canvasSelection.current.call(zoom.current.transform, zoomIdentity);
      canvasSelection.current.on('.zoom', null);
    };
  }, [enabled, width, height, scaleMax, canvasRefChanged]);

  // Create programmatic `zoomIn` function for control buttons
  const zoomIn = useCallback(() => {
    if (!enabled) return;
    canvasSelection.current.transition().call(zoom.current.scaleBy, zoomBy);
  }, [enabled, zoomBy]);

  // Create programmatic `zoomOut` function for control buttons
  const zoomOut = useCallback(() => {
    if (!enabled) return;
    canvasSelection.current.transition().call(zoom.current.scaleBy, 1 / zoomBy);
  }, [enabled, zoomBy]);

  // Create programmatic `zoomReset` function for control buttons
  const zoomReset = useCallback(() => {
    if (!enabled) return;
    canvasSelection.current.transition().call(zoom.current.transform, zoomIdentity);
  }, [enabled]);

  // Precompute some logs we'll need to calculate `zoomLevel`
  const logScaleMin = useMemo(() => Math.log2(1), []);
  const logScaleMax = useMemo(() => Math.log2(scaleMax), [scaleMax]);

  // This is the `zoom` value that we're going to return
  const zoomValue = useMemo(() => {
    // Calculate the linear zoomLevel from the multiplicative scale factor
    const logK = Math.log2(transform.k);
    const zoomLevel = (logK - logScaleMin) / (logScaleMax - logScaleMin);
    const viewWidth = viewBottomRightX - viewTopLeftX;
    return {
      // The current transform of the canvas
      transform: {
        ...transform,
        zoomLevel
      },
      // Dimensions of the canvas
      canvas: {
        svgWidth: width,
        svgHeight: height,
        nodeWidth: canvasPixelWidth,
        nodeHeight: canvasPixelHeight,
        svgToPixelScale: canvasPixelWidth / viewWidth || 1
      },
      // Ref that needs to be attached to the SVG containing div
      containerRef,
      // Ref that needs to be attached to the SVG canvas node
      canvasRef: canvasRefCallback,
      // Transform control functions
      zoomIn,
      zoomOut,
      zoomReset,
      setTransform,
      // Info about the zoom configuration
      config: {
        zoomable,
        size: [width, height],
        viewExtent: [[viewTopLeftX, viewTopLeftY], [viewBottomRightX, viewBottomRightY]],
        scaleMax,
        zoomBy
      },
      instructionOverlay,
      clearInstructionOverlay
    };
  }, [zoomable, width, height, viewTopLeftX, viewTopLeftY, viewBottomRightX, viewBottomRightY, scaleMax, zoomBy, logScaleMax, logScaleMin, transform, containerRef, canvasRefCallback, zoomIn, zoomOut, zoomReset, setTransform, canvasPixelWidth, canvasPixelHeight, instructionOverlay, clearInstructionOverlay]);

  // If we're controlled then we can ignore our zoom value and just return the
  // controlled value we were given
  return _controlledZoomValue || zoomValue;
};
export default useZoom;