import * as React from "react";

import * as ReactDOM from "react-dom";
import { isEqual, minBy, maxBy } from "lodash";
// NOTE: webpack didn't seem to like the import * as SVGPanZoom syntax
const svgPanZoom = require("svg-pan-zoom");

export class PanZoom extends React.Component<any, any> {
  constructor(props) {
    super(props);
    this.state = {
      ready: false, // Is the diagram ready to pan or zoom?
      panZoom: null,
      shouldZoom: props.zoomLevel || props.zoomedEntities,
      shouldPan: props.panCoordinates || props.pannedEntities,
      // Since users can manually drag the diagram, props are only initial, state provides actual pan/zoom state
      zoomedEntities: props.zoomedEntities || [],
      pannedEntities: props.pannedEntities || [],
      // Takes precedence over panned/zoomed entities
      panCoordinates: props.panCoordinates,
      zoomLevel: props.zoomLevel
    };

    window.addEventListener("resize", this.onResize);
  }

  onResize = () => {
    const { panZoom } = this.state;
    if (!panZoom) return;
    const {
      pannedEntities = [],
      zoomedEntities = [],
      diagram,
      panCoordinates,
      zoomLevel
    } = this.props;
    // Respond to changes in window size
    panZoom.resize();
    panZoom.fit();
    panZoom.center();

    // Wait for the diagram to resize and center
    // This is not the best way but it the simplest
    // Relatively high timeout just in case resizing takes a while
    setTimeout(() => {
      this.setState({
        shouldPan: true,
        shouldZoom: true,
        ready: true,
        panCoordinates,
        zoomLevel,
        pannedEntities,
        zoomedEntities
      });
    }, 500);
  };

  onInit(panZoomInstance) {
    const {
      pannedEntities = [],
      zoomedEntities = [],
      diagram,
      panCoordinates,
      zoomLevel
    } = this.props;
    this.setState({
      panZoom: panZoomInstance,
      ready: true,
      shouldZoom: zoomedEntities || zoomLevel,
      shouldPan: pannedEntities || panCoordinates,
      panCoordinates,
      zoomLevel,
      pannedEntities,
      zoomedEntities
    });
  }

  componentWillReceiveProps(nextProps) {
    // Because of ref behaviour, this is called without changing props manually
    const prevProps = this.props;
    const {
      pannedEntities = [],
      zoomedEntities = [],
      diagram,
      panCoordinates,
      zoomLevel
    } = nextProps;
    if (!isEqual(diagram, prevProps.diagram)) {
      this.init(diagram);
    } else {
      this.setState({
        shouldZoom: zoomedEntities || zoomLevel,
        shouldPan: pannedEntities || panCoordinates,
        panCoordinates,
        zoomLevel,
        pannedEntities,
        zoomedEntities
      });
    }
  }

  componentDidUpdate(prevProps, prevState) {
    // Whenever the state or props change, we check if the component should pan or zoom
    // If ONE of them is needed, it is performed
    // By calling setState in the then callback, this will be called again
    // So if both of them are needed, they are performed one after the other
    const {
      shouldPan,
      shouldZoom,
      ready,
      panCoordinates,
      zoomLevel,
      pannedEntities = [],
      zoomedEntities = []
    } = this.state;
    if (shouldPan && ready) {
      let promise;
      if (panCoordinates) promise = this.panToCoordinates(panCoordinates);
      else promise = this.panToEntities(pannedEntities);

      promise.then(() => this.setState({ shouldPan: false }));
    } else if (shouldZoom && ready) {
      let promise;
      if (zoomLevel) promise = this.zoomToLevel(zoomLevel);
      else promise = this.zoomOnEntities(zoomedEntities);

      promise.then(() => this.setState({ shouldZoom: false }));
    }
  }

  destroy = () => {
    const { panZoom } = this.state;
    if (!panZoom) return;
    panZoom.destroy();
    this.setState({
      ready: false,
      shouldPan: false,
      shouldZoom: false,
      panZoom: null
    });
  };

  init = diagram => {
    const { showPanZoomControls } = this.props;
    this.destroy(); // Destroy the diagram first in case there is one
    let node: SVGElement = ReactDOM.findDOMNode(diagram) as SVGElement;
    const pan = svgPanZoom(node, {
      viewportSelector: ".svg-pan-zoom_viewport",
      controlIconsEnabled: showPanZoomControls,
      fit: true,
      center: true,
      minZoom: 0.1,
      maxZoom: 20.0,
      //zoomEnabled: false,
      zoomEnabled: true,
      customEventsHandler: {
        init: options => {
          this.onInit(options.instance);
          const { onReady } = this.props;
          onReady();
        },
        haltEventListeners: [],
        destroy: () => {}
      },
      beforeZoom: () => {
        const { locked } = this.props;
        const { ready, shouldZoom } = this.state;
        // Don't allow if not ready or locked
        // shouldZoom is only true if a programmatic zoom is set in props, still allow this
        if (!ready || (locked && !shouldZoom)) return false;
        // Don't allow any more zooming until done
        // Reset the zoomedEntities since the diagram has moved and we can't be sure they are still zoomed on
        this.setState({ ready: false, zoomedEntities: [] });
        return true;
      },
      onZoom: this.handleChange,
      beforePan: () => {
        const { locked } = this.props;
        const { ready, shouldPan } = this.state;
        // Don't allow if not ready or locked
        // shouldPan is only true if a programmatic pan is set in props, still allow this
        if (!ready || (locked && !shouldPan)) return false;
        // Don't allow any more panning until done
        // Reset pannedEntities since the diagram has moved and we can't be sure they are still panned on
        this.setState({ ready: false, pannedEntities: [] });
        return true;
      },
      onPan: this.handleChange,
      onUpdatedCTM: () => this.setState({ ready: true })
    });
  };

  handleChange = () => {
    const { panZoom } = this.state;
    const { onChange } = this.props;
    const pan = panZoom.getPan();
    const sizes = panZoom.getSizes();
    const relX = pan.x / sizes.width;
    const relY = pan.y / sizes.height;
    const zoomLevel = panZoom.getZoom();
    if (onChange) onChange({ x: relX, y: relY, zoomLevel });
  };

  panToCoordinates(coordinates: { x: number; y: number }): Promise<{}> {
    const { panZoom } = this.state;
    const sizes = panZoom.getSizes();
    const absCoordinates = {
      x: coordinates.x * sizes.width,
      y: coordinates.y * sizes.height
    };

    return new Promise(resolve => {
      panZoom.setOnPan(() => {
        this.handleChange();
        panZoom.setOnPan(this.handleChange);
        resolve();
      });
      panZoom.pan(absCoordinates);
    });
  }

  zoomToLevel(level: number): Promise<{}> {
    const { panZoom } = this.state;
    return new Promise(resolve => {
      panZoom.setOnZoom(() => {
        this.handleChange();
        panZoom.setOnZoom(this.handleChange);
        resolve();
      });
      // Use zoomAtPoint since the coordinates change with level
      panZoom.zoomAtPoint(level, panZoom.getPan());
    });
  }

  zoomOnEntities(zoomedEntities): Promise<{}> {
    const { panZoom } = this.state;

    return new Promise(resolve => {
      if (!zoomedEntities || zoomedEntities.length < 1) {
        panZoom.resetZoom();
        resolve();
        return;
      }

      panZoom.setOnZoom(() => {
        this.handleChange();
        panZoom.setOnZoom(this.handleChange);
        resolve();
      });

      const BBox = this.locate(zoomedEntities);
      const containerBBox = panZoom.getSizes();
      const scalingFactor = 0.8;

      if (BBox.width >= BBox.height) {
        panZoom.zoom(
          (containerBBox.width / BBox.width) *
            scalingFactor *
            containerBBox.realZoom
        );
        return;
      }
      panZoom.zoom(
        (containerBBox.height / BBox.height) *
          scalingFactor *
          containerBBox.realZoom
      );
    });
  }

  panToEntities(pannedEntities): Promise<{}> {
    const { panZoom } = this.state;

    return new Promise(resolve => {
      if (!pannedEntities || pannedEntities.length < 1) {
        panZoom.center();
        resolve();
        return;
      }
      panZoom.setOnPan(() => {
        this.handleChange();
        panZoom.setOnPan(this.handleChange);
        resolve();
      });

      const BBox = this.locate(pannedEntities);
      const curPan = panZoom.getPan();
      const containerSizes = panZoom.getSizes();

      const coordinates = {
        x: -BBox.x - BBox.width / 2 + curPan.x + containerSizes.width / 2,
        y: -BBox.y - BBox.height / 2 + curPan.y + containerSizes.height / 2
      };
      panZoom.pan(coordinates);
    });
  }

  locate(entities: string[]) {
    const { diagram } = this.props;
    const { panZoom } = this.state;
    const diagramDOMNode: SVGSVGElement = ReactDOM.findDOMNode(
      diagram
    ) as SVGSVGElement;

    // This may not be the best algorithm to do this. It will compute the BBox for every entity
    // TODO: Increase efficiency of this
    const BBoxes = entities
      .map(
        singleEntity =>
          diagramDOMNode.querySelector(`g#${singleEntity}`) as SVGSVGElement
      )
      .filter(locatedEntity => !!locatedEntity)
      .map(locatedEntity =>
        Object.assign(
          {},
          { BBox: locatedEntity.getBBox(), matrix: locatedEntity.getCTM() }
        )
      )
      .map(coords => {
        const relPoint = diagramDOMNode.createSVGPoint();
        relPoint.x = coords.BBox.x;
        relPoint.y = coords.BBox.y;
        const newRelPoint = relPoint.matrixTransform(coords.matrix);
        const realZoom = panZoom.getSizes().realZoom;
        return {
          x: newRelPoint.x,
          y: newRelPoint.y,
          width: coords.BBox.width * realZoom,
          height: coords.BBox.height * realZoom
        };
      });

    if (BBoxes.length === 1) {
      return BBoxes[0];
    }

    const minX = minBy(BBoxes, BBox => BBox.x).x;
    const minY = minBy(BBoxes, BBox => BBox.y).y;
    const maxX = maxBy(BBoxes, BBox => BBox.x).x;
    const maxY = maxBy(BBoxes, BBox => BBox.y).y;
    return {
      x: minX,
      y: minY,
      height: maxY - minY,
      width: maxX - minX
    };
  }

  render() {
    // Can put custom Kaavio controls here
    // TODO: Make custom Kaavio PanZoom controls
    return null;
  }
}
