import _get from 'lodash/get';
import _each from 'lodash/each';
import _isEqual from 'lodash/isEqual';
import _tail from 'lodash/tail';
import _throttle from 'lodash/throttle';
import colornames from 'colornames';
import pubsub from 'pubsub-js';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import * as THREE from 'three';
import {
  IMPERIAL_UNITS,
  METRIC_UNITS
} from 'app/constants';
import CombinedCamera from 'app/lib/three/CombinedCamera';
import TrackballControls from 'app/lib/three/TrackballControls';
import * as WebGL from 'app/lib/three/WebGL';
import log from 'app/lib/log';
import { getRenderPixelRatio } from 'app/lib/pixel-ratio';
import { mapValueToUnits } from 'app/lib/units';
import store from 'app/store';
import { getBoundingBox, loadSTL, loadTexture } from './helpers';
import Viewport from './Viewport';
import CoordinateAxes from './CoordinateAxes';
import Cuboid from './Cuboid';
import CuttingPointer from './CuttingPointer';
import GridLine from './GridLine';
import PivotPoint3 from './PivotPoint3';
import TextSprite from './TextSprite';
import GCodeVisualizer from './GCodeVisualizer';
import ProbeVisualization from './ProbeVisualization';
import {
  CAMERA_MODE_PAN,
  CAMERA_MODE_ROTATE
} from './constants';

const IMPERIAL_GRID_COUNT = 32; // 32 in
const IMPERIAL_GRID_SPACING = 25.4; // 1 in
const IMPERIAL_AXIS_LENGTH = IMPERIAL_GRID_SPACING * 12; // 12 in
const METRIC_GRID_COUNT = 60; // 60 cm
const METRIC_GRID_SPACING = 10; // 10 mm
const METRIC_AXIS_LENGTH = METRIC_GRID_SPACING * 30; // 300 mm
const CAMERA_VIEWPORT_WIDTH = 300; // 300 mm
const CAMERA_VIEWPORT_HEIGHT = 300; // 300 mm
const PERSPECTIVE_FOV = 70;
const PERSPECTIVE_NEAR = 0.001;
const PERSPECTIVE_FAR = 2000;
const ORTHOGRAPHIC_FOV = 35;
const ORTHOGRAPHIC_NEAR = 0.001;
const ORTHOGRAPHIC_FAR = 2000;
const CAMERA_DISTANCE = 200; // Move the camera out a bit from the origin (0, 0, 0)
const TRACKBALL_CONTROLS_MIN_DISTANCE = 1;
const TRACKBALL_CONTROLS_MAX_DISTANCE = 2000;

class Visualizer extends Component {
    static propTypes = {
      show: PropTypes.bool,
      cameraPosition: PropTypes.oneOf(['top', '3d', 'front', 'left', 'right']),
      state: PropTypes.object
    };

    pubsubTokens = [];

    isAgitated = false;

    machinePosition = {
      x: 0,
      y: 0,
      z: 0
    };

    workPosition = {
      x: 0,
      y: 0,
      z: 0
    };

    // Initialized to null so the first changeMachineProfile() call in
    // componentDidMount detects a difference against the store value and runs
    // the full pivot/rebuild pipeline. Pre-hydrating from the store here would
    // make _isEqual short-circuit and leave pivotPoint at (0, 0, 0).
    machineProfile = null;

    group = new THREE.Group();

    probeVisualization = null;

    pivotPoint = new PivotPoint3({ x: 0, y: 0, z: 0 }, (x, y, z) => { // relative position
      _each(this.group.children, (o) => {
        o.translateX(x);
        o.translateY(y);
        o.translateZ(z);
      });
    });

    node = null;

    setRef = (node) => {
      this.node = node;
    };

    throttledResize = _throttle(() => {
      this.resizeRenderer();
    }, 32); // 60hz

    // Pivot policy. The pivot determines the "machine - pivot" frame the
    // whole scene is rendered in: limits, cutting tool, probe viz, gcode
    // toolpath, and (via rebuildCoordinateSystems) grid + axes all sit at
    // "machine_coords - pivot" so the visible workspace center is at world
    // origin and TrackballControls' default target = (0, 0, 0) orbits that
    // visible center.
    //
    // Pivot is set by this method, load(), and unload(). Combined behavior:
    //
    //   Scenario                                | Pivot after        | Gcode position
    //   ----------------------------------------|--------------------|----------------------
    //   Reload, profile saved, no gcode         | profile center     | -
    //   Manual profile selection, no gcode      | profile center     | -
    //   Manual profile selection, gcode loaded  | gcode bbox center  | at world origin
    //   Profile removed, no gcode               | (0, 0, 0)          | -
    //   Profile removed, gcode loaded           | gcode bbox center  | at world origin
    //   Load gcode                              | gcode bbox center  | at world origin
    //   Unload gcode, profile selected          | profile center     | -
    //   Unload gcode, no profile                | (0, 0, 0)          | -
    //
    // For the two "gcode loaded" rows above the pivot was already at the
    // gcode bbox center (set by load()) and is intentionally left alone, so
    // the toolpath stays visually centered in the viewport across profile
    // changes; only the limits/grid rebuild for the new profile dimensions.
    //
    // The `!this.gcodeVisualizer` check distinguishes the two states.
    // `this.gcodeVisualizer` is the GCodeVisualizer instance — null until
    // load() runs, nulled again by unload() — so it is a direct local source
    // of truth and works the same way regardless of who triggered
    // changeMachineProfile (mount, store change, etc.).
    changeMachineProfile = () => {
      const machineProfile = store.get('workspace.machineProfile');

      if (_isEqual(machineProfile, this.machineProfile)) {
        return;
      }

      this.machineProfile = machineProfile ? { ...machineProfile } : null;

      // Profile removed — reset to defaults and return
      if (!machineProfile) {
        if (!this.gcodeVisualizer) {
          this.pivotPoint.set(0, 0, 0);
        }
        this.updateCuttingToolPosition();
        this.updateCuttingPointerPosition();
        this.updateLimitsPosition();
        this.updateProbeVisualizationPosition();
        if (this.group) {
          this.rebuildCoordinateSystems();
        }
        this.updateScene();
        return;
      }

      if (this.limits) {
        this.group.remove(this.limits);
        this.limits = null;
      }

      const state = this.props.state;
      const limits = _get(this.machineProfile, 'limits');
      const { xmin = 0, xmax = 0, ymin = 0, ymax = 0, zmin = 0, zmax = 0 } = { ...limits };
      this.limits = this.createLimits(xmin, xmax, ymin, ymax, zmin, zmax);
      this.limits.name = 'Limits';
      this.limits.visible = state.objects.limits.visible;
      this.group.add(this.limits);

      // Set pivot to the center of the machine profile work area (XY only).
      // Skipped when gcode is loaded so the toolpath stays centered.
      if (!this.gcodeVisualizer) {
        const centerX = (xmin + xmax) / 2;
        const centerY = (ymin + ymax) / 2;
        this.pivotPoint.set(centerX, centerY, 0);
      }

      this.updateCuttingToolPosition();
      this.updateCuttingPointerPosition();
      this.updateLimitsPosition();
      this.updateProbeVisualizationPosition();

      // Rebuild grid and axes to match the new machine profile dimensions
      if (this.group) {
        this.rebuildCoordinateSystems();
      }

      this.updateScene();
    };

    renderAnimationLoop = () => {
      if (this.isAgitated) {
        // Call the render() function up to 60 times per second (i.e. 60fps)
        requestAnimationFrame(this.renderAnimationLoop);

        const rpm = 600;
        this.rotateCuttingTool(rpm);
      } else {
        const rpm = 0;
        this.rotateCuttingTool(rpm);
      }

      // Update the scene
      this.updateScene();
    };

    constructor(props) {
      super(props);

      // Three.js
      this.renderer = null;
      this.scene = null;
      this.camera = null;
      this.controls = null;
      this.viewport = null;
      this.cuttingTool = null;
      this.cuttingPointer = null;
      this.limits = null;
      this.gcodeVisualizer = null;
    }

    componentDidMount() {
      this.subscribe();
      this.addResizeEventListener();
      store.on('change', this.changeMachineProfile);
      if (this.node) {
        const el = ReactDOM.findDOMNode(this.node);
        this.createScene(el);
        this.resizeRenderer();
      }

      // Apply any machine profile already in the store (e.g., hydrated from
      // localStorage on page reload). The store 'change' listener above only
      // fires on subsequent updates, so without this call the saved profile
      // never reaches the scene until the user re-selects it.
      this.changeMachineProfile();
    }

    componentDidUpdate(prevProps) {
      let forceUpdate = false;
      let needUpdateScene = false;
      const prevState = prevProps.state;
      const state = this.props.state;

      // Enable or disable 3D view
      if ((prevProps.show !== this.props.show) && (this.props.show === true)) {
        this.viewport.update();

        // Set forceUpdate to true when enabling or disabling 3D view
        forceUpdate = true;
        needUpdateScene = true;
      }

      // Update gcode visualizer's frame index
      if (this.gcodeVisualizer) {
        const frameIndex = state.gcode.sent;
        this.gcodeVisualizer.setFrameIndex(frameIndex);
      }

      // Projection
      if (prevState.projection !== state.projection) {
        if (state.projection === 'orthographic') {
          this.camera.toOrthographic();
          this.camera.setZoom(1);
          this.camera.setFov(ORTHOGRAPHIC_FOV);
        } else {
          this.camera.toPerspective();
          this.camera.setZoom(1);
          this.camera.setFov(PERSPECTIVE_FOV);
        }
        if (this.viewport) {
          this.viewport.update();
        }
        needUpdateScene = true;
      }

      // Camera Mode
      if (prevState.cameraMode !== state.cameraMode) {
        this.setCameraMode(state.cameraMode);
        needUpdateScene = true;
      }

      // Whether to show coordinate system
      if ((prevState.units !== state.units) ||
            (prevState.objects.coordinateSystem.visible !== state.objects.coordinateSystem.visible)) {
        const visible = state.objects.coordinateSystem.visible;

        // Imperial
        const imperialCoordinateSystem = this.group.getObjectByName('ImperialCoordinateSystem');
        if (imperialCoordinateSystem) {
          imperialCoordinateSystem.visible = visible && (state.units === IMPERIAL_UNITS);
        }

        // Metric
        const metricCoordinateSystem = this.group.getObjectByName('MetricCoordinateSystem');
        if (metricCoordinateSystem) {
          metricCoordinateSystem.visible = visible && (state.units === METRIC_UNITS);
        }

        needUpdateScene = true;
      }

      // Whether to show grid line numbers
      if ((prevState.units !== state.units) ||
            (prevState.objects.gridLineNumbers.visible !== state.objects.gridLineNumbers.visible)) {
        const visible = state.objects.gridLineNumbers.visible;

        // Imperial
        const imperialGridLineNumbers = this.group.getObjectByName('ImperialGridLineNumbers');
        if (imperialGridLineNumbers) {
          imperialGridLineNumbers.visible = visible && (state.units === IMPERIAL_UNITS);
        }

        // Metric
        const metricGridLineNumbers = this.group.getObjectByName('MetricGridLineNumbers');
        if (metricGridLineNumbers) {
          metricGridLineNumbers.visible = visible && (state.units === METRIC_UNITS);
        }

        needUpdateScene = true;
      }

      // Whether to show limits
      if (this.limits && (this.limits.visible !== state.objects.limits.visible)) {
        this.limits.visible = state.objects.limits.visible;
        needUpdateScene = true;
      }

      // Whether to show cutting tool or cutting pointer
      if (this.cuttingTool && this.cuttingPointer && (this.cuttingTool.visible !== state.objects.cuttingTool.visible)) {
        this.cuttingTool.visible = state.objects.cuttingTool.visible;
        this.cuttingPointer.visible = !state.objects.cuttingTool.visible;
        needUpdateScene = true;
      }

      { // Update position
        let needUpdatePosition = false;

        // Machine position
        const { x: mpox0, y: mpoy0, z: mpoz0 } = this.machinePosition;
        const { x: mpox1, y: mpoy1, z: mpoz1 } = state.machinePosition;
        if (mpox0 !== mpox1 || mpoy0 !== mpoy1 || mpoz0 !== mpoz1) {
          this.machinePosition = state.machinePosition;
          needUpdatePosition = true;
          needUpdateScene = true;
        }

        // Work position
        const { x: wpox0, y: wpoy0, z: wpoz0 } = this.workPosition;
        const { x: wpox1, y: wpoy1, z: wpoz1 } = state.workPosition;
        if (wpox0 !== wpox1 || wpoy0 !== wpoy1 || wpoz0 !== wpoz1) {
          this.workPosition = state.workPosition;
          needUpdatePosition = true;
          needUpdateScene = true;
        }

        if (needUpdatePosition) {
          this.updateCuttingToolPosition();
          this.updateCuttingPointerPosition();
          this.updateLimitsPosition();
          this.updateProbeVisualizationPosition();
        }
      }

      if (needUpdateScene) {
        this.updateScene({ forceUpdate: forceUpdate });
      }

      if (this.isAgitated !== state.isAgitated) {
        this.isAgitated = state.isAgitated;

        if (this.isAgitated) {
          // Call renderAnimationLoop when the state changes and isAgitated is true
          requestAnimationFrame(this.renderAnimationLoop);
        }
      }

      if (prevProps.cameraPosition !== this.props.cameraPosition) {
        if (this.props.cameraPosition === 'top') {
          this.toTopView();
        }
        if (this.props.cameraPosition === '3d') {
          this.to3DView();
        }
        if (this.props.cameraPosition === 'front') {
          this.toFrontView();
        }
        if (this.props.cameraPosition === 'left') {
          this.toLeftSideView();
        }
        if (this.props.cameraPosition === 'right') {
          this.toRightSideView();
        }
      }
    }

    componentWillUnmount() {
      this.unsubscribe();
      this.removeResizeEventListener();
      store.removeListener('change', this.changeMachineProfile);
      this.clearScene();
    }

    subscribe() {
      const tokens = [
        pubsub.subscribe('resize', (msg) => {
          this.resizeRenderer();
        }),
        pubsub.subscribe('autolevel:showProbeVisualization', (msg, data) => {
          this.showProbeVisualization(data);
        }),
        pubsub.subscribe('autolevel:hideProbeVisualization', (msg) => {
          this.hideProbeVisualization();
        }),
        pubsub.subscribe('autolevel:updateProbeVisualization', (msg, data) => {
          log.info('[Visualizer] Received updateProbeVisualization event:', data);

          if (this.probeVisualization && typeof this.probeVisualization.updateBounds === 'function') {
            const { startX, startY, endX, endY, snapX, snapY, interactable } = data.config;
            log.info('[Visualizer] Updating bounds to:', { startX, startY, endX, endY, snapX, snapY, interactable });

            // Update snap config if provided
            if (this.probeVisualization.config) {
              if (snapX !== undefined) {
                this.probeVisualization.config.snapX = snapX;
              }
              if (snapY !== undefined) {
                this.probeVisualization.config.snapY = snapY;
              }
            }

            this.probeVisualization.updateBounds(startX, startY, endX, endY);

            // Enable or disable interactions based on interactable flag
            if (typeof this.probeVisualization.setInteractable === 'function' && interactable !== undefined) {
              this.probeVisualization.setInteractable(interactable);
            }

            this.updateScene({ forceUpdate: true });
            log.debug('[Visualizer] Updated probe visualization bounds from AutoLevel');
          } else {
            log.warn('[Visualizer] Cannot update bounds - probeVisualization or updateBounds not available');
          }
        })
      ];
      this.pubsubTokens = this.pubsubTokens.concat(tokens);
    }

    unsubscribe() {
      this.pubsubTokens.forEach((token) => {
        pubsub.unsubscribe(token);
      });
      this.pubsubTokens = [];
    }

    showProbeVisualization(data) {
      const { probeData = [], config = {} } = data;

      log.debug('[Visualizer] showProbeVisualization', { probeData: probeData.length, config });

      // If probe visualization doesn't exist, create it once
      if (!this.probeVisualization) {
        this.probeVisualization = new ProbeVisualization(
          probeData,
          config,
          this.camera,
          this.renderer.domElement,
          this.controls,
          () => this.updateScene({ forceUpdate: true }) // Scene update callback for smooth drag
        );
        this.probeVisualization.group.visible = false; // Start hidden
        this.group.add(this.probeVisualization.group);
        log.info('[Visualizer] Created and added probe visualization to group');
      } else {
        // Update existing visualization with new config
        const { startX, startY, endX, endY, snapX, snapY, interactable, units } = config;

        // Update all config values
        if (this.probeVisualization.config) {
          if (snapX !== undefined) {
            this.probeVisualization.config.snapX = snapX;
          }
          if (snapY !== undefined) {
            this.probeVisualization.config.snapY = snapY;
          }
          if (units !== undefined) {
            this.probeVisualization.config.units = units;
          }
          if (interactable !== undefined) {
            this.probeVisualization.config.interactable = interactable;
          }
        }

        // Update probe data (surface and points) - call with empty array to clear old data
        if (typeof this.probeVisualization.updateProbeData === 'function') {
          this.probeVisualization.updateProbeData(probeData);
        }

        if (typeof this.probeVisualization.updateBounds === 'function') {
          this.probeVisualization.updateBounds(startX, startY, endX, endY);
        }
        // Recreate interactive elements with new bounds
        if (typeof this.probeVisualization.recreateInteractiveElements === 'function') {
          this.probeVisualization.recreateInteractiveElements();
        }
        // Enable or disable interactions based on interactable flag
        if (typeof this.probeVisualization.setInteractable === 'function') {
          this.probeVisualization.setInteractable(interactable !== undefined ? interactable : false);
        }
      }

      // Position the group to account for pivot point (like cutting tool)
      this.updateProbeVisualizationPosition();

      // Make visible
      this.probeVisualization.group.visible = true;
      this.updateScene({ forceUpdate: true });
    }

    hideProbeVisualization() {
      if (this.probeVisualization) {
        log.debug('[Visualizer] hideProbeVisualization');

        // Just hide, don't dispose (keeps events bound for next show)
        this.probeVisualization.group.visible = false;
        this.updateScene({ forceUpdate: true });
      }
    }

    // https://tylercipriani.com/blog/2014/07/12/crossbrowser-javascript-scrollbar-detection/
    hasVerticalScrollbar() {
      return window.innerWidth > document.documentElement.clientWidth;
    }

    hasHorizontalScrollbar() {
      return window.innerHeight > document.documentElement.clientHeight;
    }

    // http://www.alexandre-gomes.com/?p=115
    getScrollbarWidth() {
      const inner = document.createElement('p');
      inner.style.width = '100%';
      inner.style.height = '200px';

      const outer = document.createElement('div');
      outer.style.position = 'absolute';
      outer.style.top = '0px';
      outer.style.left = '0px';
      outer.style.visibility = 'hidden';
      outer.style.width = '200px';
      outer.style.height = '150px';
      outer.style.overflow = 'hidden';
      outer.appendChild(inner);

      document.body.appendChild(outer);
      const w1 = inner.offsetWidth;
      outer.style.overflow = 'scroll';
      const w2 = (w1 === inner.offsetWidth) ? outer.clientWidth : inner.offsetWidth;
      document.body.removeChild(outer);

      return (w1 - w2);
    }

    getVisibleWidth() {
      const el = ReactDOM.findDOMNode(this.node);
      const visibleWidth = Math.max(
        Number(el && el.parentNode && el.parentNode.clientWidth) || 0,
        360
      );

      return visibleWidth;
    }

    getVisibleHeight() {
      const clientHeight = document.documentElement.clientHeight;
      const navbarHeight = 50;
      const widgetHeaderHeight = 38;
      const widgetFooterHeight = 38;
      const visibleHeight = (
        clientHeight - navbarHeight - widgetHeaderHeight - widgetFooterHeight - 1
      );

      return visibleHeight;
    }

    addResizeEventListener() {
      window.addEventListener('resize', this.throttledResize);
    }

    removeResizeEventListener() {
      window.removeEventListener('resize', this.throttledResize);
    }

    resizeRenderer() {
      if (!(this.camera && this.renderer)) {
        return;
      }

      const width = this.getVisibleWidth();
      const height = this.getVisibleHeight();

      if (width === 0 || height === 0) {
        log.warn(`The width (${width}) and height (${height}) cannot be a zero value`);
      }

      // https://github.com/mrdoob/three.js/blob/dev/examples/js/cameras/CombinedCamera.js#L156
      // THREE.CombinedCamera.prototype.setSize = function(width, height) {
      //     this.cameraP.aspect = width / height;
      //     this.left = - width / 2;
      //     this.right = width / 2;
      //     this.top = height / 2;
      //     this.bottom = - height / 2;
      // }
      this.camera.setSize(width, height);
      this.camera.aspect = width / height; // Update camera aspect as well
      this.camera.updateProjectionMatrix();

      // Initialize viewport at the first time of resizing renderer
      if (!this.viewport) {
        // Defaults to 300x300mm
        this.viewport = new Viewport(this.camera, CAMERA_VIEWPORT_WIDTH, CAMERA_VIEWPORT_HEIGHT);
      }

      this.controls.handleResize();

      this.renderer.setPixelRatio(getRenderPixelRatio());
      this.renderer.setSize(width, height);

      // Update the scene
      this.updateScene();
    }

    createLimits(xmin, xmax, ymin, ymax, zmin, zmax) {
      const dx = Math.abs(xmax - xmin) || Number.MIN_VALUE;
      const dy = Math.abs(ymax - ymin) || Number.MIN_VALUE;
      const dz = Math.abs(zmax - zmin) || Number.MIN_VALUE;
      const color = colornames('indianred');
      const opacity = 0.5;
      const transparent = true;
      const dashed = true;
      const dashSize = 3; // The size of the dash.
      const gapSize = 1; // The size of the gap.
      const linewidth = 1; // Controls line thickness.
      const scale = 1; // The scale of the dashed part of a line.
      const limits = new Cuboid({
        dx,
        dy,
        dz,
        color,
        opacity,
        transparent,
        linewidth,
        dashed,
        dashSize,
        gapSize,
        scale,
      });

      return limits;
    }

    // Derive grid and axis bounds from machine profile limits (if set) or
    // fall back to the fixed defaults for the given unit system.
    getCoordinateBounds(units) {
      const gridSpacing = (units === IMPERIAL_UNITS) ? IMPERIAL_GRID_SPACING : METRIC_GRID_SPACING;
      const limits = _get(this.machineProfile, 'limits');
      const { xmin = 0, xmax = 0, ymin = 0, ymax = 0, zmin = 0, zmax = 0 } = { ...limits };
      const hasMachineProfile = (xmax - xmin) > 0 || (ymax - ymin) > 0;

      if (hasMachineProfile) {
        return {
          minX: xmin,
          maxX: xmax,
          minY: ymin,
          maxY: ymax,
          minZ: zmin,
          maxZ: zmax,
          gridSpacing,
        };
      }

      // Default symmetric grid
      const gridCount = (units === IMPERIAL_UNITS) ? IMPERIAL_GRID_COUNT : METRIC_GRID_COUNT;
      const axisLength = (units === IMPERIAL_UNITS) ? IMPERIAL_AXIS_LENGTH : METRIC_AXIS_LENGTH;
      const size = gridCount * gridSpacing;
      return {
        minX: -size,
        maxX: size,
        minY: -size,
        maxY: size,
        minZ: -axisLength,
        maxZ: axisLength,
        gridSpacing,
      };
    }

    createCoordinateSystem(units) {
      const { minX, maxX, minY, maxY, minZ, maxZ, gridSpacing } = this.getCoordinateBounds(units);
      const labelOffset = gridSpacing * 2;
      const group = new THREE.Group();

      { // Coordinate Grid
        const gridLine = new GridLine(minX, maxX, gridSpacing, minY, maxY, gridSpacing, colornames('blue'), colornames('gray 44'));
        _each(gridLine.children, (o) => {
          o.material.opacity = 0.15;
          o.material.transparent = true;
          o.material.depthWrite = false;
        });
        gridLine.name = 'GridLine';
        group.add(gridLine);
      }

      { // Coordinate Axes — extend to the full grid extent
        const coordinateAxes = new CoordinateAxes({ minX, maxX, minY, maxY, minZ, maxZ });
        coordinateAxes.name = 'CoordinateAxes';
        group.add(coordinateAxes);
      }

      { // Axis Labels — placed just beyond the positive end of each axis
        const axisXLabel = new TextSprite({
          x: maxX + labelOffset,
          y: 0,
          z: 0,
          size: 20,
          text: 'X',
          color: colornames('red')
        });
        const axisYLabel = new TextSprite({
          x: 0,
          y: maxY + labelOffset,
          z: 0,
          size: 20,
          text: 'Y',
          color: colornames('green')
        });
        const axisZLabel = new TextSprite({
          x: 0,
          y: 0,
          z: maxZ + labelOffset,
          size: 20,
          text: 'Z',
          color: colornames('blue')
        });

        group.add(axisXLabel);
        group.add(axisYLabel);
        group.add(axisZLabel);
      }

      return group;
    }

    createGridLineNumbers(units) {
      const { minX, maxX, minY, maxY, gridSpacing } = this.getCoordinateBounds(units);
      const textSize = (units === IMPERIAL_UNITS) ? (25.4 / 3) : (10 / 3);
      const textOffset = (units === IMPERIAL_UNITS) ? (25.4 / 5) : (10 / 5);
      const group = new THREE.Group();

      // X-axis labels
      for (let x = minX; x <= maxX; x += gridSpacing) {
        if (x !== 0) {
          group.add(new TextSprite({
            x,
            y: textOffset,
            z: 0,
            size: textSize,
            // Scene coords are mm; mapValueToUnits converts and trims
            // trailing zeros (e.g. 25.4 mm → 1 in, 10 mm → 10 mm).
            text: mapValueToUnits(x, units),
            textAlign: 'center',
            textBaseline: 'bottom',
            color: colornames('red'),
            opacity: 0.5
          }));
        }
      }

      // Y-axis labels
      for (let y = minY; y <= maxY; y += gridSpacing) {
        if (y !== 0) {
          group.add(new TextSprite({
            x: -textOffset,
            y,
            z: 0,
            size: textSize,
            text: mapValueToUnits(y, units),
            textAlign: 'right',
            textBaseline: 'middle',
            color: colornames('green'),
            opacity: 0.5
          }));
        }
      }

      return group;
    }

    rebuildCoordinateSystems() {
      const { state } = this.props;
      const { units, objects } = state;

      ['ImperialCoordinateSystem', 'MetricCoordinateSystem',
        'ImperialGridLineNumbers', 'MetricGridLineNumbers'].forEach(name => {
        const obj = this.group.getObjectByName(name);
        if (obj) {
          this.group.remove(obj);
        }
      });

      // Grid/axes geometry is built at raw machine coords; shift each group by
      // -pivotPoint so it shares the same frame as the limits, cutting tool,
      // probe visualization, and gcode toolpath (all of which render at
      // "machine coords - pivotPoint").
      const pp = this.pivotPoint.get();
      const positionAtPivot = (group) => {
        group.position.set(-pp.x, -pp.y, -pp.z);
      };

      const imperialCoordinateSystem = this.createCoordinateSystem(IMPERIAL_UNITS);
      imperialCoordinateSystem.name = 'ImperialCoordinateSystem';
      imperialCoordinateSystem.visible = objects.coordinateSystem.visible && (units === IMPERIAL_UNITS);
      positionAtPivot(imperialCoordinateSystem);
      this.group.add(imperialCoordinateSystem);

      const metricCoordinateSystem = this.createCoordinateSystem(METRIC_UNITS);
      metricCoordinateSystem.name = 'MetricCoordinateSystem';
      metricCoordinateSystem.visible = objects.coordinateSystem.visible && (units === METRIC_UNITS);
      positionAtPivot(metricCoordinateSystem);
      this.group.add(metricCoordinateSystem);

      const imperialGridLineNumbers = this.createGridLineNumbers(IMPERIAL_UNITS);
      imperialGridLineNumbers.name = 'ImperialGridLineNumbers';
      imperialGridLineNumbers.visible = objects.gridLineNumbers.visible && (units === IMPERIAL_UNITS);
      positionAtPivot(imperialGridLineNumbers);
      this.group.add(imperialGridLineNumbers);

      const metricGridLineNumbers = this.createGridLineNumbers(METRIC_UNITS);
      metricGridLineNumbers.name = 'MetricGridLineNumbers';
      metricGridLineNumbers.visible = objects.gridLineNumbers.visible && (units === METRIC_UNITS);
      positionAtPivot(metricGridLineNumbers);
      this.group.add(metricGridLineNumbers);
    }

    //
    // Creating a scene
    // http://threejs.org/docs/#Manual/Introduction/Creating_a_scene
    //
    createScene(el) {
      if (!el) {
        return;
      }

      const { state } = this.props;
      const { units, objects } = state;
      const width = this.getVisibleWidth();
      const height = this.getVisibleHeight();

      // WebGLRenderer
      this.renderer = new THREE.WebGLRenderer({
        autoClearColor: true,
        antialias: true,
        alpha: true
      });
      this.renderer.shadowMap.enabled = true;
      this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      this.renderer.setClearColor(new THREE.Color(colornames('white')), 1);
      this.renderer.setPixelRatio(getRenderPixelRatio());
      this.renderer.setSize(width, height);
      this.renderer.clear();

      el.appendChild(this.renderer.domElement);

      // To actually be able to display anything with Three.js, we need three things:
      // A scene, a camera, and a renderer so we can render the scene with the camera.
      this.scene = new THREE.Scene();

      this.camera = this.createCombinedCamera(width, height);
      this.controls = this.createTrackballControls(this.camera, this.renderer.domElement);

      this.setCameraMode(state.cameraMode);

      // Projection
      if (state.projection === 'orthographic') {
        this.camera.toOrthographic();
        this.camera.setZoom(1);
        this.camera.setFov(ORTHOGRAPHIC_FOV);
      } else {
        this.camera.toPerspective();
        this.camera.setZoom(1);
        this.camera.setFov(PERSPECTIVE_FOV);
      }

      { // Directional Light
        const color = 0xffffff;
        const intensity = 1;
        let light;

        light = new THREE.DirectionalLight(color, intensity);
        light.position.set(-1, -1, 1);
        this.scene.add(light);

        light = new THREE.DirectionalLight(color, intensity);
        light.position.set(1, -1, 1);
        this.scene.add(light);
      }

      { // Ambient Light
        const light = new THREE.AmbientLight(colornames('gray 25')); // soft white light
        this.scene.add(light);
      }

      // Coordinate systems and grid line numbers (Imperial + Metric) are
      // created via rebuildCoordinateSystems so the initial render uses the
      // same pivot-shifted frame as later rebuilds.
      this.rebuildCoordinateSystems();

      { // Cutting Tool
        Promise.all([
          loadSTL('assets/models/stl/bit.stl').then(geometry => geometry),
          loadTexture('assets/textures/brushed-steel-texture.jpg').then(texture => texture),
        ]).then(result => {
          const [geometry, texture] = result;

          // Rotate the geometry 90 degrees about the X axis.
          geometry.rotateX(-Math.PI / 2);

          // Scale the geometry data.
          geometry.scale(0.5, 0.5, 0.5);

          // Compute the bounding box.
          geometry.computeBoundingBox();

          // Set the desired position from the origin rather than its center.
          const height = geometry.boundingBox.max.z - geometry.boundingBox.min.z;
          geometry.translate(0, 0, (height / 2));

          let material;
          if (geometry.hasColors) {
            material = new THREE.MeshLambertMaterial({
              map: texture,
              opacity: 0.9,
              transparent: false
            });
          }

          const object = new THREE.Object3D();
          object.add(new THREE.Mesh(geometry, material));

          this.cuttingTool = object;
          this.cuttingTool.name = 'CuttingTool';
          this.cuttingTool.visible = objects.cuttingTool.visible;

          this.group.add(this.cuttingTool);

          // The STL/texture load is async, so the tool may be added to the
          // group after changeMachineProfile() has already run during mount.
          // Sync its position to the current pivot/WPos so it lands at the
          // correct spot instead of the default (0, 0, 0) (which would be the
          // visible workspace center under any non-trivial machine profile).
          this.updateCuttingToolPosition();

          // Update the scene
          this.updateScene();
        });
      }

      { // Cutting Pointer
        this.cuttingPointer = new CuttingPointer({
          color: colornames('indianred'),
          diameter: 2
        });
        this.cuttingPointer.name = 'CuttingPointer';
        this.cuttingPointer.visible = !objects.cuttingTool.visible;
        this.group.add(this.cuttingPointer);
      }

      { // Limits
        const limits = _get(this.machineProfile, 'limits');
        const { xmin = 0, xmax = 0, ymin = 0, ymax = 0, zmin = 0, zmax = 0 } = { ...limits };
        this.limits = this.createLimits(xmin, xmax, ymin, ymax, zmin, zmax);
        this.limits.name = 'Limits';
        this.limits.visible = objects.limits.visible;
        this.group.add(this.limits);

        this.updateLimitsPosition();
      }

      { // Probe Visualization
        // Create with default bounds, will be updated when shown
        const defaultConfig = {
          startX: 0,
          startY: 0,
          endX: 10,
          endY: 10,
          units: units
        };
        this.probeVisualization = new ProbeVisualization(
          [], // No probe data initially
          defaultConfig,
          this.camera,
          this.renderer.domElement,
          this.controls,
          () => this.updateScene({ forceUpdate: true })
        );
        this.probeVisualization.group.name = 'ProbeVisualization';
        this.probeVisualization.group.visible = false; // Hidden by default
        this.group.add(this.probeVisualization.group);
      }

      this.scene.add(this.group);
    }

    // @param [options] The options object.
    // @param [options.forceUpdate] Force rendering
    updateScene(options) {
      const { forceUpdate = false } = { ...options };
      const needUpdateScene = this.props.show || forceUpdate;

      if (this.renderer && needUpdateScene) {
        this.renderer.render(this.scene, this.camera);
      }
    }

    clearScene() {
      // Dispose probe visualization events before clearing
      if (this.probeVisualization && typeof this.probeVisualization.dispose === 'function') {
        this.probeVisualization.dispose();
      }

      // to iterrate over all children (except the first) in a scene
      const objsToRemove = _tail(this.scene.children);
      _each(objsToRemove, (obj) => {
        this.scene.remove(obj);
      });

      if (this.controls) {
        this.controls.dispose();
      }

      // Update the scene
      this.updateScene();
    }

    createCombinedCamera(width, height) {
      const frustumWidth = width / 2;
      const frustumHeight = (height || width) / 2; // same to width if height is 0
      const fov = PERSPECTIVE_FOV;
      const near = PERSPECTIVE_NEAR;
      const far = PERSPECTIVE_FAR;
      const orthoNear = ORTHOGRAPHIC_NEAR;
      const orthoFar = ORTHOGRAPHIC_FAR;

      const camera = new CombinedCamera(
        frustumWidth,
        frustumHeight,
        fov,
        near,
        far,
        orthoNear,
        orthoFar
      );

      camera.position.x = 0;
      camera.position.y = 0;
      camera.position.z = CAMERA_DISTANCE;

      return camera;
    }

    createPerspectiveCamera(width, height) {
      const fov = PERSPECTIVE_FOV;
      const aspect = (width > 0 && height > 0) ? Number(width) / Number(height) : 1;
      const near = PERSPECTIVE_NEAR;
      const far = PERSPECTIVE_FAR;
      const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

      camera.position.x = 0;
      camera.position.y = 0;
      camera.position.z = CAMERA_DISTANCE;

      return camera;
    }

    createOrthographicCamera(width, height) {
      const left = -width / 2;
      const right = width / 2;
      const top = height / 2;
      const bottom = -height / 2;
      const near = ORTHOGRAPHIC_NEAR;
      const far = ORTHOGRAPHIC_FAR;
      const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);

      return camera;
    }

    createTrackballControls(object, domElement) {
      const controls = new TrackballControls(object, domElement);

      controls.rotateSpeed = Math.PI;
      controls.zoomSpeed = 1.2;
      controls.panSpeed = 1.0;
      controls.noZoom = false;
      controls.noPan = false;

      controls.staticMoving = true;
      controls.dynamicDampingFactor = 0.3;

      controls.keys = [65, 83, 68];

      controls.minDistance = TRACKBALL_CONTROLS_MIN_DISTANCE;
      controls.maxDistance = TRACKBALL_CONTROLS_MAX_DISTANCE;

      let shouldAnimate = false;
      const animate = () => {
        controls.update();

        // Update the scene
        this.updateScene();

        if (shouldAnimate) {
          requestAnimationFrame(animate);
        }
      };

      controls.addEventListener('start', () => {
        shouldAnimate = true;
        animate();
      });
      controls.addEventListener('end', () => {
        shouldAnimate = false;
        this.updateScene();
      });
      controls.addEventListener('change', () => {
        // Update the scene
        this.updateScene();
      });

      return controls;
    }

    // Rotates the cutting tool around the z axis with a given rpm and an optional fps
    // @param {number} rpm The rounds per minutes
    // @param {number} [fps] The frame rate (Defaults to 60 frames per second)
    rotateCuttingTool(rpm = 0, fps = 60) {
      if (!this.cuttingTool) {
        return;
      }

      const delta = 1 / fps;
      const degrees = 360 * (delta * Math.PI / 180); // Rotates 360 degrees per second
      this.cuttingTool.rotateZ(-(rpm / 60 * degrees)); // rotate in clockwise direction
    }

    // Update cutting tool position
    updateCuttingToolPosition() {
      if (!this.cuttingTool) {
        return;
      }

      const pivotPoint = this.pivotPoint.get();
      const { x: wpox, y: wpoy, z: wpoz } = this.workPosition;
      const x0 = wpox - pivotPoint.x;
      const y0 = wpoy - pivotPoint.y;
      const z0 = wpoz - pivotPoint.z;

      this.cuttingTool.position.set(x0, y0, z0);
    }

    // Update cutting pointer position
    updateCuttingPointerPosition() {
      if (!this.cuttingPointer) {
        return;
      }

      const pivotPoint = this.pivotPoint.get();
      const { x: wpox, y: wpoy, z: wpoz } = this.workPosition;
      const x0 = wpox - pivotPoint.x;
      const y0 = wpoy - pivotPoint.y;
      const z0 = wpoz - pivotPoint.z;

      this.cuttingPointer.position.set(x0, y0, z0);
    }

    // Update limits position
    updateLimitsPosition() {
      if (!this.limits) {
        return;
      }

      // Limits represent the machine's fixed envelope and stay anchored to the
      // grid regardless of the active work coordinate system.
      const limits = _get(this.machineProfile, 'limits');
      const { xmin = 0, xmax = 0, ymin = 0, ymax = 0, zmin = 0, zmax = 0 } = { ...limits };
      const pivotPoint = this.pivotPoint.get();
      const x0 = ((xmin + xmax) / 2) - pivotPoint.x;
      const y0 = ((ymin + ymax) / 2) - pivotPoint.y;
      const z0 = ((zmin + zmax) / 2) - pivotPoint.z;

      this.limits.position.set(x0, y0, z0);
    }

    // Update probe visualization position
    updateProbeVisualizationPosition() {
      if (!this.probeVisualization) {
        return;
      }

      const pivotPoint = this.pivotPoint.get();
      this.probeVisualization.group.position.set(-pivotPoint.x, -pivotPoint.y, -pivotPoint.z);
    }

    // Make the controls look at the specified position
    lookAt(x, y, z) {
      this.controls.target.x = x;
      this.controls.target.y = y;
      this.controls.target.z = z;
      this.controls.update();
    }

    // Make the controls look at the center position
    lookAtCenter() {
      if (this.viewport) {
        this.viewport.update();
      }
      if (this.controls) {
        this.controls.reset();
      }
      this.updateScene();
    }

    load(name, gcode, callback) {
      // Remove previous G-code object
      this.unload();

      this.gcodeVisualizer = new GCodeVisualizer();

      const obj = this.gcodeVisualizer.render(gcode);
      obj.name = 'Visualizer';
      this.group.add(obj);

      const bbox = getBoundingBox(obj);
      const dX = bbox.max.x - bbox.min.x;
      const dY = bbox.max.y - bbox.min.y;
      const dZ = bbox.max.z - bbox.min.z;
      const center = new THREE.Vector3(
        bbox.min.x + (dX / 2),
        bbox.min.y + (dY / 2),
        bbox.min.z + (dZ / 2)
      );

      // Set the pivot point to the center of the loaded object
      this.pivotPoint.set(center.x, center.y, center.z);

      // Explicitly anchor the gcode mesh so its bounding-box center lands at
      // world origin, independent of the pivot-delta callback's translation
      // history. Without this, when unload() leaves the pivot at the machine
      // profile's center (rather than (0, 0, 0)), the delta from
      // profile_center → gcode_center would land the mesh at world
      // profile_center instead of world origin.
      obj.position.set(-center.x, -center.y, -center.z);

      // Update position
      this.updateCuttingToolPosition();
      this.updateCuttingPointerPosition();
      this.updateLimitsPosition();
      this.updateProbeVisualizationPosition();

      if (this.viewport && dX > 0 && dY > 0) {
        // The minimum viewport is 50x50mm
        const width = Math.max(dX, 50);
        const height = Math.max(dY, 50);
        const target = new THREE.Vector3(0, 0, bbox.max.z);
        this.viewport.set(width, height, target);
      }

      // Update the scene
      this.updateScene();

      (typeof callback === 'function') && callback({ bbox: bbox });
    }

    unload() {
      const visualizerObject = this.group.getObjectByName('Visualizer');
      if (visualizerObject) {
        this.group.remove(visualizerObject);
      }

      if (this.gcodeVisualizer) {
        this.gcodeVisualizer = null;
      }

      if (this.pivotPoint) {
        // Reset pivot to the machine profile's XY center (or origin if no
        // profile) so the scene stays in the same "machine - pivot" frame as
        // changeMachineProfile produces. This keeps the visible workspace
        // centered at world origin and the orbit pivot (controls.target =
        // (0, 0, 0)) at the visible center after unloading gcode.
        if (!this.machineProfile) {
          this.pivotPoint.set(0, 0, 0);
        } else {
          const limits = _get(this.machineProfile, 'limits');
          const { xmin = 0, xmax = 0, ymin = 0, ymax = 0 } = { ...limits };
          const centerX = (xmin + xmax) / 2;
          const centerY = (ymin + ymax) / 2;
          this.pivotPoint.set(centerX, centerY, 0);
        }

        // Update positions after resetting pivot point
        this.updateCuttingToolPosition();
        this.updateCuttingPointerPosition();
        this.updateLimitsPosition();
        this.updateProbeVisualizationPosition();

        if (this.group) {
          this.rebuildCoordinateSystems();
        }
      }

      if (this.controls) {
        this.controls.reset();
      }

      if (this.viewport) {
        this.viewport.reset();
      }

      // Update the scene
      this.updateScene();
    }

    setCameraMode(mode) {
      // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
      // A number representing a given button:
      // 0: main button pressed, usually the left button or the un-initialized state
      const MAIN_BUTTON = 0;
      const ROTATE = 0;
      const PAN = 2;

      if (mode === CAMERA_MODE_ROTATE) {
        this.controls && this.controls.setMouseButtonState(MAIN_BUTTON, ROTATE);
      }
      if (mode === CAMERA_MODE_PAN) {
        this.controls && this.controls.setMouseButtonState(MAIN_BUTTON, PAN);
      }
    }

    toTopView() {
      if (this.controls) {
        this.controls.reset();
      }

      this.camera.up.set(0, 1, 0);
      this.camera.position.set(0, 0, CAMERA_DISTANCE);

      if (this.viewport) {
        this.viewport.update();
      }
      if (this.controls) {
        this.controls.update();
      }
      this.updateScene();
    }

    to3DView() {
      if (this.controls) {
        this.controls.reset();
      }

      this.camera.up.set(0, 0, 1);
      this.camera.position.set(CAMERA_DISTANCE, -CAMERA_DISTANCE, CAMERA_DISTANCE);

      if (this.viewport) {
        this.viewport.update();
      }
      if (this.controls) {
        this.controls.update();
      }
      this.updateScene();
    }

    toFrontView() {
      if (this.controls) {
        this.controls.reset();
      }

      this.camera.up.set(0, 0, 1);
      this.camera.position.set(0, -CAMERA_DISTANCE, 0);

      if (this.viewport) {
        this.viewport.update();
      }
      if (this.controls) {
        this.controls.update();
      }
      this.updateScene();
    }

    toLeftSideView() {
      if (this.controls) {
        this.controls.reset();
      }

      this.camera.up.set(0, 0, 1);
      this.camera.position.set(CAMERA_DISTANCE, 0, 0);

      if (this.viewport) {
        this.viewport.update();
      }
      if (this.controls) {
        this.controls.update();
      }
    }

    toRightSideView() {
      if (this.controls) {
        this.controls.reset();
      }

      this.camera.up.set(0, 0, 1);
      this.camera.position.set(-CAMERA_DISTANCE, 0, 0);

      if (this.viewport) {
        this.viewport.update();
      }
      if (this.controls) {
        this.controls.update();
      }
      this.updateScene();
    }

    zoomFit() {
      if (this.viewport) {
        this.viewport.update();
      }
      this.updateScene();
    }

    zoomIn(delta = 0.1) {
      const { noZoom } = this.controls;
      if (noZoom) {
        return;
      }

      this.controls.zoomIn(delta);
      this.controls.update();

      // Update the scene
      this.updateScene();
    }

    zoomOut(delta = 0.1) {
      const { noZoom } = this.controls;
      if (noZoom) {
        return;
      }

      this.controls.zoomOut(delta);
      this.controls.update();

      // Update the scene
      this.updateScene();
    }

    // deltaX and deltaY are in pixels; right and down are positive
    pan(deltaX, deltaY) {
      const eye = new THREE.Vector3();
      const pan = new THREE.Vector3();
      const objectUp = new THREE.Vector3();

      eye.subVectors(this.controls.object.position, this.controls.target);
      objectUp.copy(this.controls.object.up);

      pan.copy(eye).cross(objectUp.clone()).setLength(deltaX);
      pan.add(objectUp.clone().setLength(deltaY));

      this.controls.object.position.add(pan);
      this.controls.target.add(pan);
      this.controls.update();
    }

    // http://stackoverflow.com/questions/18581225/orbitcontrol-or-trackballcontrol
    panUp() {
      const { noPan, panSpeed } = this.controls;
      !noPan && this.pan(0, 1 * panSpeed);
    }

    panDown() {
      const { noPan, panSpeed } = this.controls;
      !noPan && this.pan(0, -1 * panSpeed);
    }

    panLeft() {
      const { noPan, panSpeed } = this.controls;
      !noPan && this.pan(1 * panSpeed, 0);
    }

    panRight() {
      const { noPan, panSpeed } = this.controls;
      !noPan && this.pan(-1 * panSpeed, 0);
    }

    render() {
      if (!WebGL.isWebGLAvailable()) {
        return null;
      }

      return (
        <div
          aria-label="3D Visualizer"
          style={{
            visibility: this.props.show ? 'visible' : 'hidden'
          }}
          ref={this.setRef}
        />
      );
    }
}

export default Visualizer;