import { select as d3Select } from 'd3-selection';
import { zoom as d3Zoom, zoomTransform as d3ZoomTransform } from 'd3-zoom';
import { drag as d3Drag } from 'd3-drag';
import { max as d3Max, min as d3Min, sum as d3Sum } from 'd3-array';
import { throttle } from 'lodash-es';
import { Tween, Group as TweenGroup, Easing } from '@tweenjs/tween.js';
import Kapsule from 'kapsule';
import accessorFn from 'accessor-fn';
import ColorTracker from 'canvas-color-tracker';
import Tooltip from 'float-tooltip';

import CanvasForceGraph from './canvas-force-graph';
import linkKapsule from './kapsule-link.js';

const HOVER_CANVAS_THROTTLE_DELAY = 800; // ms to throttle shadow canvas updates for perf improvement
const ZOOM2NODES_FACTOR = 4;
const DRAG_CLICK_TOLERANCE_PX = 5; // How many px can a node be accidentally dragged before disabling the click

// Expose config from forceGraph
const bindFG = linkKapsule('forceGraph', CanvasForceGraph);
const bindBoth = linkKapsule(['forceGraph', 'shadowGraph'], CanvasForceGraph);
const linkedProps = Object.assign(
  ...[
    'nodeColor',
    'nodeAutoColorBy',
    'nodeCanvasObject',
    'nodeCanvasObjectMode',
    'linkColor',
    'linkAutoColorBy',
    'linkLineDash',
    'linkWidth',
    'linkCanvasObject',
    'linkCanvasObjectMode',
    'linkDirectionalArrowLength',
    'linkDirectionalArrowColor',
    'linkDirectionalArrowRelPos',
    'linkDirectionalParticles',
    'linkDirectionalParticleSpeed',
    'linkDirectionalParticleOffset',
    'linkDirectionalParticleWidth',
    'linkDirectionalParticleColor',
    'linkDirectionalParticleCanvasObject',
    'dagMode',
    'dagLevelDistance',
    'dagNodeFilter',
    'onDagError',
    'd3AlphaMin',
    'd3AlphaDecay',
    'd3VelocityDecay',
    'warmupTicks',
    'cooldownTicks',
    'cooldownTime',
    'onEngineTick',
    'onEngineStop'
  ].map(p => ({ [p]: bindFG.linkProp(p)})),
  ...[
    'nodeRelSize',
    'nodeId',
    'nodeVal',
    'nodeVisibility',
    'linkSource',
    'linkTarget',
    'linkVisibility',
    'linkCurvature'
  ].map(p => ({ [p]: bindBoth.linkProp(p)}))
);
const linkedMethods = Object.assign(...[
  'd3Force',
  'd3ReheatSimulation',
  'emitParticle'
].map(p => ({ [p]: bindFG.linkMethod(p)})));

function adjustCanvasSize(state) {
  if (state.canvas) {
    let curWidth = state.canvas.width;
    let curHeight = state.canvas.height;
    if (curWidth === 300 && curHeight === 150) { // Default canvas dimensions
      curWidth = curHeight = 0;
    }

    const pxScale = window.devicePixelRatio; // 2 on retina displays
    curWidth /= pxScale;
    curHeight /= pxScale;

    // Resize canvases
    [state.canvas, state.shadowCanvas].forEach(canvas => {
      // Element size
      canvas.style.width = `${state.width}px`;
      canvas.style.height = `${state.height}px`;

      // Memory size (scaled to avoid blurriness)
      canvas.width = state.width * pxScale;
      canvas.height = state.height * pxScale;

      // Normalize coordinate system to use css pixels (on init only)
      if (!curWidth && !curHeight) {
        canvas.getContext('2d').scale(pxScale, pxScale);
      }
    });

    // Relative center panning based on 0,0
    const k = d3ZoomTransform(state.canvas).k;
    state.zoom.translateBy(state.zoom.__baseElem,
      (state.width - curWidth) / 2 / k,
      (state.height - curHeight) / 2 / k
    );
    state.needsRedraw = true;
  }
}

function resetTransform(ctx) {
  const pxRatio = window.devicePixelRatio;
  ctx.setTransform(pxRatio, 0, 0, pxRatio, 0, 0);
}

function clearCanvas(ctx, width, height) {
  ctx.save();
  resetTransform(ctx);  // reset transform
  ctx.clearRect(0, 0, width, height);
  ctx.restore();        //restore transforms
}

//

export default Kapsule({
  props:{
    width: { default: window.innerWidth, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false } ,
    height: { default: window.innerHeight, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false },
    graphData: {
      default: { nodes: [], links: [] },
      onChange: ((d, state) => {
        // Wipe color registry if all objects are new
        [d.nodes, d.links].every(arr => (arr || []).every(d => !d.hasOwnProperty('__indexColor'))) && state.colorTracker.reset();

        [{ type: 'Node', objs: d.nodes }, { type: 'Link', objs: d.links }].forEach(hexIndex);
        state.forceGraph.graphData(d);
        state.shadowGraph.graphData(d);

        function hexIndex({ type, objs }) {
          objs
            .filter(d => {
              if (!d.hasOwnProperty('__indexColor')) return true;
              const cur = state.colorTracker.lookup(d.__indexColor);
              return (!cur || !cur.hasOwnProperty('d') || cur.d !== d);
            })
            .forEach(d => {
              // store object lookup color
              d.__indexColor = state.colorTracker.register({ type, d });
            });
        }
      }),
      triggerUpdate: false
    },
    backgroundColor: { onChange(color, state) { state.canvas && color && (state.canvas.style.background = color) }, triggerUpdate: false },
    nodeLabel: { default: 'name', triggerUpdate: false },
    nodePointerAreaPaint: { onChange(paintFn, state) {
      state.shadowGraph.nodeCanvasObject(!paintFn ? null :
        (node, ctx, globalScale) => paintFn(node, node.__indexColor, ctx, globalScale)
      );
      state.flushShadowCanvas && state.flushShadowCanvas();
    }, triggerUpdate: false },
    linkPointerAreaPaint: { onChange(paintFn, state) {
      state.shadowGraph.linkCanvasObject(!paintFn ? null :
        (link, ctx, globalScale) => paintFn(link, link.__indexColor, ctx, globalScale)
      );
      state.flushShadowCanvas && state.flushShadowCanvas();
    }, triggerUpdate: false },
    linkLabel: { default: 'name', triggerUpdate: false },
    linkHoverPrecision: { default: 4, triggerUpdate: false },
    minZoom: { default: 0.01, onChange(minZoom, state) { state.zoom.scaleExtent([minZoom, state.zoom.scaleExtent()[1]]); }, triggerUpdate: false },
    maxZoom: { default: 1000, onChange(maxZoom, state) { state.zoom.scaleExtent([state.zoom.scaleExtent()[0], maxZoom]) }, triggerUpdate: false },
    enableNodeDrag: { default: true, triggerUpdate: false },
    enableZoomInteraction: { default: true, triggerUpdate: false },
    enablePanInteraction: { default: true, triggerUpdate: false },
    enableZoomPanInteraction: { default: true, triggerUpdate: false }, // to be deprecated
    enablePointerInteraction: { default: true, onChange(_, state) { state.hoverObj = null; }, triggerUpdate: false },
    autoPauseRedraw: { default: true, triggerUpdate: false },
    onNodeDrag: { default: () => {}, triggerUpdate: false },
    onNodeDragEnd: { default: () => {}, triggerUpdate: false },
    onNodeClick: { triggerUpdate: false },
    onNodeRightClick: { triggerUpdate: false },
    onNodeHover: { triggerUpdate: false },
    onLinkClick: { triggerUpdate: false },
    onLinkRightClick: { triggerUpdate: false },
    onLinkHover: { triggerUpdate: false },
    onBackgroundClick: { triggerUpdate: false },
    onBackgroundRightClick: { triggerUpdate: false },
    showPointerCursor: { default: true, triggerUpdate: false },
    onZoom: { triggerUpdate: false },
    onZoomEnd: { triggerUpdate: false },
    onRenderFramePre: { triggerUpdate: false },
    onRenderFramePost: { triggerUpdate: false },
    ...linkedProps
  },

  aliases: { // Prop names supported for backwards compatibility
    stopAnimation: 'pauseAnimation'
  },

  methods: {
    graph2ScreenCoords: function(state, x, y) {
      const t = d3ZoomTransform(state.canvas);
      return { x: x * t.k  + t.x, y: y * t.k + t.y };
    },
    screen2GraphCoords: function(state, x, y) {
      const t = d3ZoomTransform(state.canvas);
      return { x: (x - t.x) / t.k, y: (y - t.y) / t.k };
    },
    centerAt: function(state, x, y, transitionDuration) {
      if (!state.canvas) return null; // no canvas yet

      // setter
      if (x !== undefined || y !== undefined) {
        const finalPos = Object.assign({},
          x !== undefined ? { x } : {},
          y !== undefined ? { y } : {}
        );
        if (!transitionDuration) { // no animation
          setCenter(finalPos);
        } else {
          state.tweenGroup.add(
            new Tween(getCenter())
              .to(finalPos, transitionDuration)
              .easing(Easing.Quadratic.Out)
              .onUpdate(setCenter)
              .onComplete(function() { state.tweenGroup.remove(this) })
              .start()
          );
        }
        return this;
      }

      // getter
      return getCenter();

      //

      function getCenter() {
        const t = d3ZoomTransform(state.canvas);
        return { x: (state.width / 2 - t.x) / t.k, y: (state.height / 2 - t.y) / t.k };
      }

      function setCenter({ x, y }) {
        state.zoom.translateTo(
          state.zoom.__baseElem,
          x === undefined ? getCenter().x : x,
          y === undefined ? getCenter().y : y
        );
        state.needsRedraw = true;
      }
    },
    zoom: function(state, k, transitionDuration) {
      if (!state.canvas) return null; // no canvas yet

      // setter
      if (k !== undefined) {
        if (!transitionDuration) { // no animation
          setZoom(k);
        } else {
          state.tweenGroup.add(
            new Tween({ k: getZoom() })
              .to({ k }, transitionDuration)
              .easing(Easing.Quadratic.Out)
              .onUpdate(({ k }) => setZoom(k))
              .onComplete(function() { state.tweenGroup.remove(this) })
              .start()
          );
        }
        return this;
      }

      // getter
      return getZoom();

      //

      function getZoom() {
        return d3ZoomTransform(state.canvas).k;
      }

      function setZoom(k) {
        state.zoom.scaleTo(state.zoom.__baseElem, k);
        state.needsRedraw = true;
      }
    },
    zoomToFit: function(state, transitionDuration = 0, padding = 10, ...bboxArgs) {
      const bbox = this.getGraphBbox(...bboxArgs);
      
      if (bbox) {
        const center = {
          x: (bbox.x[0] + bbox.x[1]) / 2,
          y: (bbox.y[0] + bbox.y[1]) / 2,
        };

        const zoomK = Math.max(1e-12, Math.min(1e12,
          (state.width - padding * 2) / (bbox.x[1] - bbox.x[0]),
          (state.height - padding * 2) / (bbox.y[1] - bbox.y[0]))
        );

        this.centerAt(center.x, center.y, transitionDuration);
        this.zoom(zoomK, transitionDuration);
      }

      return this;
    },
    getGraphBbox: function(state, nodeFilter = () => true) {
      const getVal = accessorFn(state.nodeVal);
      const getR = node => Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize;

      const nodesPos = state.graphData.nodes.filter(nodeFilter).map(node => ({
        x: node.x,
        y: node.y,
        r: getR(node)
      }));

      return !nodesPos.length ? null : {
        x: [
          d3Min(nodesPos, node => node.x - node.r),
          d3Max(nodesPos, node => node.x + node.r)
        ],
        y: [
          d3Min(nodesPos, node => node.y - node.r),
          d3Max(nodesPos, node => node.y + node.r)
        ]
      };
    },
    pauseAnimation: function(state) {
      if (state.animationFrameRequestId) {
        cancelAnimationFrame(state.animationFrameRequestId);
        state.animationFrameRequestId = null;
      }
      return this;
    },
    resumeAnimation: function(state) {
      if (!state.animationFrameRequestId) {
        this._animationCycle();
      }
      return this;
    },
    _destructor: function() {
      this.pauseAnimation();
      this.graphData({ nodes: [], links: []});
    },
    ...linkedMethods
  },

  stateInit: () => ({
    lastSetZoom: 1,
    zoom: d3Zoom(),
    forceGraph: new CanvasForceGraph(),
    shadowGraph: new CanvasForceGraph()
      .cooldownTicks(0)
      .nodeColor('__indexColor')
      .linkColor('__indexColor')
      .isShadow(true),
    colorTracker: new ColorTracker(), // indexed objects for rgb lookup
    tweenGroup: new TweenGroup()
  }),

  init: function(domNode, state) {
    // Wipe DOM
    domNode.innerHTML = '';

    // Container anchor for canvas and tooltip
    const container = document.createElement('div');
    container.classList.add('force-graph-container');
    container.style.position = 'relative';
    domNode.appendChild(container);

    state.canvas = document.createElement('canvas');
    if (state.backgroundColor) state.canvas.style.background = state.backgroundColor;
    container.appendChild(state.canvas);

    state.shadowCanvas = document.createElement('canvas');

    // Show shadow canvas
    //state.shadowCanvas.style.position = 'absolute';
    //state.shadowCanvas.style.top = '0';
    //state.shadowCanvas.style.left = '0';
    //container.appendChild(state.shadowCanvas);

    const ctx = state.canvas.getContext('2d');
    const shadowCtx = state.shadowCanvas.getContext('2d', { willReadFrequently: true });

    const pointerPos = { x: -1e12, y: -1e12 };
    const getObjUnderPointer = () => {
      let obj = null;
      const pxScale = window.devicePixelRatio;
      const px = (pointerPos.x > 0 && pointerPos.y > 0)
        ? shadowCtx.getImageData(pointerPos.x * pxScale, pointerPos.y * pxScale, 1, 1)
        : null;
      // Lookup object per pixel color
      px && (obj = state.colorTracker.lookup(px.data));
      return obj;
    };

    // Setup node drag interaction
    d3Select(state.canvas).call(
      d3Drag()
        .subject(() => {
          if (!state.enableNodeDrag) { return null; }
          const obj = getObjUnderPointer();
          return (obj && obj.type === 'Node') ? obj.d : null; // Only drag nodes
        })
        .on('start', ev => {
          const obj = ev.subject;
          obj.__initialDragPos = {
            x: obj.x,
            y: obj.y,
            fx: obj.fx,
            fy: obj.fy
          };

          // keep engine running at low intensity throughout drag
          if (!ev.active) {
            obj.fx = obj.x; obj.fy = obj.y; // Fix points
          }

          // drag cursor
          state.canvas.classList.add('grabbable');
        })
        .on('drag', ev => {
          const obj = ev.subject;
          const initPos = obj.__initialDragPos;
          const dragPos = ev;

          const k = d3ZoomTransform(state.canvas).k;
          const translate = {
            x: (initPos.x + (dragPos.x - initPos.x) / k) - obj.x,
            y: (initPos.y + (dragPos.y - initPos.y) / k) - obj.y
          };

          // Move fx/fy (and x/y) of nodes based on the scaled drag distance since the drag start
          ['x', 'y'].forEach(c => obj[`f${c}`] = obj[c] = initPos[c] + (dragPos[c] - initPos[c]) / k);

          // Only engage full drag if distance reaches above threshold
          if (!obj.__dragged && (DRAG_CLICK_TOLERANCE_PX >= Math.sqrt(d3Sum(['x', 'y'].map(k => (ev[k] - initPos[k])**2)))))
            return;

          state.forceGraph
            .d3AlphaTarget(0.3) // keep engine running at low intensity throughout drag
            .resetCountdown();  // prevent freeze while dragging

          state.isPointerDragging = true;

          obj.__dragged = true;
          state.onNodeDrag(obj, translate);
        })
        .on('end', ev => {
          const obj = ev.subject;
          const initPos = obj.__initialDragPos;
          const translate = {x: obj.x - initPos.x, y:  obj.y - initPos.y};

          if (initPos.fx === undefined) { obj.fx = undefined; }
          if (initPos.fy === undefined) { obj.fy = undefined; }
          delete(obj.__initialDragPos);

          if (state.forceGraph.d3AlphaTarget()) {
            state.forceGraph
              .d3AlphaTarget(0)   // release engine low intensity
              .resetCountdown();  // let the engine readjust after releasing fixed nodes
          }

          // drag cursor
          state.canvas.classList.remove('grabbable');

          state.isPointerDragging = false;

          if (obj.__dragged) {
            delete(obj.__dragged);
            state.onNodeDragEnd(obj, translate);
          }
        })
    );

    // Setup zoom / pan interaction
    state.zoom(state.zoom.__baseElem = d3Select(state.canvas)); // Attach controlling elem for easy access

    state.zoom.__baseElem.on('dblclick.zoom', null); // Disable double-click to zoom

    state.zoom
      .filter(ev =>
        // disable zoom interaction
        !ev.button
          && state.enableZoomPanInteraction
          && (ev.type !== 'wheel' || accessorFn(state.enableZoomInteraction)(ev))
          && (ev.type === 'wheel' || accessorFn(state.enablePanInteraction)(ev))
      )
      .on('zoom', ev => {
        const t = ev.transform;
        [ctx, shadowCtx].forEach(c => {
          resetTransform(c);
          c.translate(t.x, t.y);
          c.scale(t.k, t.k);
        });
        state.isPointerDragging = true;
        state.onZoom && state.onZoom({ ...t, ...this.centerAt() }); // report x,y coordinates relative to canvas center
        state.needsRedraw = true;
      })
      .on('end', ev => {
        state.isPointerDragging = false;
        state.onZoomEnd && state.onZoomEnd({ ...ev.transform, ...this.centerAt() });
      });

    adjustCanvasSize(state);

    state.forceGraph
      .onNeedsRedraw(() => state.needsRedraw = true)
      .onFinishUpdate(() => {
        // re-zoom, if still in default position (not user modified)
        if (d3ZoomTransform(state.canvas).k === state.lastSetZoom && state.graphData.nodes.length) {
          state.zoom.scaleTo(state.zoom.__baseElem,
            state.lastSetZoom = ZOOM2NODES_FACTOR / Math.cbrt(state.graphData.nodes.length)
          );
          state.needsRedraw = true;
        }
      });

    // Setup tooltip
    state.tooltip = new Tooltip(container);

    // Capture pointer coords on move or touchstart
    ['pointermove', 'pointerdown'].forEach(evType =>
      container.addEventListener(evType, ev => {
        if (evType === 'pointerdown') {
          state.isPointerPressed = true; // track click state
          state.pointerDownEvent = ev;
        }

        // detect pointer drag on canvas pan
        !state.isPointerDragging && ev.type === 'pointermove'
        && (state.onBackgroundClick) // only bother detecting drags this way if background clicks are enabled (so they don't trigger accidentally on canvas panning)
        && (ev.pressure > 0 || state.isPointerPressed) // ev.pressure always 0 on Safari, so we use the isPointerPressed tracker
        && (ev.pointerType === 'mouse' || ev.movementX === undefined || [ev.movementX, ev.movementY].some(m => Math.abs(m) > 1)) // relax drag trigger sensitivity on non-mouse (touch/pen) events
        && (state.isPointerDragging = true);

        // update the pointer pos
        const offset = getOffset(container);
        pointerPos.x = ev.pageX - offset.left;
        pointerPos.y = ev.pageY - offset.top;

        //

        function getOffset(el) {
          const rect = el.getBoundingClientRect(),
            scrollLeft = window.pageXOffset || document.documentElement.scrollLeft,
            scrollTop = window.pageYOffset || document.documentElement.scrollTop;
          return { top: rect.top + scrollTop, left: rect.left + scrollLeft };
        }
      }, { passive: true })
    );

    // Handle click/touch events on nodes/links
    container.addEventListener('pointerup', ev => {
      if (!state.isPointerPressed) {
        return; // don't trigger click events if pointer is not pressed on the canvas
      }

      state.isPointerPressed = false;
      if (state.isPointerDragging) {
        state.isPointerDragging = false;
        return; // don't trigger click events after pointer drag (pan / node drag functionality)
      }

      const cbEvents = [ev, state.pointerDownEvent];
      requestAnimationFrame(() => { // trigger click events asynchronously, to allow hoverObj to be set (on frame)
        if (ev.button === 0) { // mouse left-click or touch
          if (state.hoverObj) {
            const fn = state[`on${state.hoverObj.type}Click`];
            fn && fn(state.hoverObj.d, ...cbEvents);
          } else {
            state.onBackgroundClick && state.onBackgroundClick(...cbEvents);
          }
        }

        if (ev.button === 2) { // mouse right-click
          if (state.hoverObj) {
            const fn = state[`on${state.hoverObj.type}RightClick`];
            fn && fn(state.hoverObj.d, ...cbEvents);
          } else {
            state.onBackgroundRightClick && state.onBackgroundRightClick(...cbEvents);
          }
        }
      });
    }, { passive: true });

    container.addEventListener('contextmenu', ev => {
      if (!state.onBackgroundRightClick && !state.onNodeRightClick && !state.onLinkRightClick) return true; // default contextmenu behavior
      ev.preventDefault();
      return false;
    });

    state.forceGraph(ctx);
    state.shadowGraph(shadowCtx);

    //

    const refreshShadowCanvas = throttle(() => {
      // wipe canvas
      clearCanvas(shadowCtx, state.width, state.height);

      // Adjust link hover area
      state.shadowGraph.linkWidth(l => accessorFn(state.linkWidth)(l) + state.linkHoverPrecision);

      // redraw
      const t = d3ZoomTransform(state.canvas);
      state.shadowGraph.globalScale(t.k).tickFrame();
    }, HOVER_CANVAS_THROTTLE_DELAY);
    state.flushShadowCanvas = refreshShadowCanvas.flush; // hook to immediately invoke shadow canvas paint

    // Kick-off renderer
    (this._animationCycle = function animate() { // IIFE
      const doRedraw = !state.autoPauseRedraw || !!state.needsRedraw || state.forceGraph.isEngineRunning()
        || state.graphData.links.some(d => d.__photons && d.__photons.length);
      state.needsRedraw = false;

      if (state.enablePointerInteraction) {
        // Update tooltip and trigger onHover events
        const obj = !state.isPointerDragging ? getObjUnderPointer() : null; // don't hover during drag
        if (obj !== state.hoverObj) {
          const prevObj = state.hoverObj;
          const prevObjType = prevObj ? prevObj.type : null;
          const objType = obj ? obj.type : null;

          if (prevObjType && prevObjType !== objType) {
            // Hover out
            const fn = state[`on${prevObjType}Hover`];
            fn && fn(null, prevObj.d);
          }
          if (objType) {
            // Hover in
            const fn = state[`on${objType}Hover`];
            fn && fn(obj.d, prevObjType === objType ? prevObj.d : null);
          }

          state.tooltip.content(obj ? accessorFn(state[`${obj.type.toLowerCase()}Label`])(obj.d) || null : null);

          // set pointer if hovered object is clickable
          state.canvas.classList[
            ((obj && state[`on${objType}Click`]) || (!obj && state.onBackgroundClick)) &&
            accessorFn(state.showPointerCursor)(obj?.d) ? 'add' : 'remove'
          ]('clickable');

          state.hoverObj = obj;
        }

        doRedraw && refreshShadowCanvas();
      }

      if(doRedraw) {
        // Wipe canvas
        clearCanvas(ctx, state.width, state.height);

        // Frame cycle
        const globalScale = d3ZoomTransform(state.canvas).k;
        state.onRenderFramePre && state.onRenderFramePre(ctx, globalScale);
        state.forceGraph.globalScale(globalScale).tickFrame();
        state.onRenderFramePost && state.onRenderFramePost(ctx, globalScale);
      }

      state.tweenGroup.update(); // update canvas animation tweens

      state.animationFrameRequestId = requestAnimationFrame(animate);
    })();
  },

  update: function updateFn(state) {}
});
