// Adapted from d3-zoom to handle pointer events.
// https://github.com/d3/d3-zoom/blob/523ccff340187a3e3c044eaa4d4a7391ea97272b/src/zoom.js

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { interpolateZoom } from 'd3-interpolate';
import { select as d3_select } from 'd3-selection';
import { interrupt as d3_interrupt } from 'd3-transition';
import { zoomIdentity as d3_zoomIdentity } from 'd3-zoom';
import { Transform } from '../../node_modules/d3-zoom/src/transform.js';

import { utilFastMouse, utilFunctor } from './util';
import { utilRebind } from './rebind';

// Ignore right-click, since that should open the context menu.
function defaultFilter(d3_event) {
  return !d3_event.ctrlKey && !d3_event.button;
}

function defaultExtent() {
  var e = this;
  if (e instanceof SVGElement) {
    e = e.ownerSVGElement || e;
    if (e.hasAttribute('viewBox')) {
      e = e.viewBox.baseVal;
      return [[e.x, e.y], [e.x + e.width, e.y + e.height]];
    }
    return [[0, 0], [e.width.baseVal.value, e.height.baseVal.value]];
  }
  return [[0, 0], [e.clientWidth, e.clientHeight]];
}

function defaultWheelDelta(d3_event) {
  return -d3_event.deltaY * (d3_event.deltaMode === 1 ? 0.05 : d3_event.deltaMode ? 1 : 0.002);
}

function defaultConstrain(transform, extent, translateExtent) {
  var dx0 = transform.invertX(extent[0][0]) - translateExtent[0][0],
      dx1 = transform.invertX(extent[1][0]) - translateExtent[1][0],
      dy0 = transform.invertY(extent[0][1]) - translateExtent[0][1],
      dy1 = transform.invertY(extent[1][1]) - translateExtent[1][1];
  return transform.translate(
    dx1 > dx0 ? (dx0 + dx1) / 2 : Math.min(0, dx0) || Math.max(0, dx1),
    dy1 > dy0 ? (dy0 + dy1) / 2 : Math.min(0, dy0) || Math.max(0, dy1)
  );
}

export function utilZoomPan() {
  var filter = defaultFilter,
      extent = defaultExtent,
      constrain = defaultConstrain,
      wheelDelta = defaultWheelDelta,
      scaleExtent = [0, Infinity],
      translateExtent = [[-Infinity, -Infinity], [Infinity, Infinity]],
      interpolate = interpolateZoom,
      dispatch = d3_dispatch('start', 'zoom', 'end'),
      _wheelDelay = 150,
      _transform = d3_zoomIdentity,
      _activeGesture;

  function zoom(selection) {
    selection
        .on('pointerdown.zoom', pointerdown)
        .on('wheel.zoom', wheeled)
        .style('touch-action', 'none')
        .style('-webkit-tap-highlight-color', 'rgba(0,0,0,0)');

    d3_select(window)
        .on('pointermove.zoompan', pointermove)
        .on('pointerup.zoompan pointercancel.zoompan', pointerup);
  }

  zoom.transform = function(collection, transform, point) {
    var selection = collection.selection ? collection.selection() : collection;
    if (collection !== selection) {
      schedule(collection, transform, point);
    } else {
      selection.interrupt().each(function() {
        gesture(this, arguments)
            .start(null)
            .zoom(null, null, typeof transform === 'function' ? transform.apply(this, arguments) : transform)
            .end(null);
      });
    }
  };

  zoom.scaleBy = function(selection, k, p) {
    zoom.scaleTo(selection, function() {
      var k0 = _transform.k,
          k1 = typeof k === 'function' ? k.apply(this, arguments) : k;
      return k0 * k1;
    }, p);
  };

  zoom.scaleTo = function(selection, k, p) {
    zoom.transform(selection, function() {
      var e = extent.apply(this, arguments),
          t0 = _transform,
          p0 = !p ? centroid(e) : typeof p === 'function' ? p.apply(this, arguments) : p,
          p1 = t0.invert(p0),
          k1 = typeof k === 'function' ? k.apply(this, arguments) : k;
      return constrain(translate(scale(t0, k1), p0, p1), e, translateExtent);
    }, p);
  };

  zoom.translateBy = function(selection, x, y) {
    zoom.transform(selection, function() {
      return constrain(_transform.translate(
        typeof x === 'function' ? x.apply(this, arguments) : x,
        typeof y === 'function' ? y.apply(this, arguments) : y
      ), extent.apply(this, arguments), translateExtent);
    });
  };

  zoom.translateTo = function(selection, x, y, p) {
    zoom.transform(selection, function() {
      var e = extent.apply(this, arguments),
          t = _transform,
          p0 = !p ? centroid(e) : typeof p === 'function' ? p.apply(this, arguments) : p;
      return constrain(d3_zoomIdentity.translate(p0[0], p0[1]).scale(t.k).translate(
        typeof x === 'function' ? -x.apply(this, arguments) : -x,
        typeof y === 'function' ? -y.apply(this, arguments) : -y
      ), e, translateExtent);
    }, p);
  };

  function scale(transform, k) {
    k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], k));
    return k === transform.k ? transform : new Transform(k, transform.x, transform.y);
  }

  function translate(transform, p0, p1) {
    var x = p0[0] - p1[0] * transform.k, y = p0[1] - p1[1] * transform.k;
    return x === transform.x && y === transform.y ? transform : new Transform(transform.k, x, y);
  }

  function centroid(extent) {
    return [(+extent[0][0] + +extent[1][0]) / 2, (+extent[0][1] + +extent[1][1]) / 2];
  }

  function schedule(transition, transform, point) {
    transition
        .on('start.zoom', function() { gesture(this, arguments).start(null); })
        .on('interrupt.zoom end.zoom', function() { gesture(this, arguments).end(null); })
        .tween('zoom', function() {
          var that = this,
              args = arguments,
              g = gesture(that, args),
              e = extent.apply(that, args),
              p = !point ? centroid(e) : typeof point === 'function' ? point.apply(that, args) : point,
              w = Math.max(e[1][0] - e[0][0], e[1][1] - e[0][1]),
              a = _transform,
              b = typeof transform === 'function' ? transform.apply(that, args) : transform,
              i = interpolate(a.invert(p).concat(w / a.k), b.invert(p).concat(w / b.k));
          return function(t) {
            if (t === 1) {
              // Avoid rounding error on end.
              t = b;
            } else {
              var l = i(t);
              var k = w / l[2];
              t = new Transform(k, p[0] - l[0] * k, p[1] - l[1] * k);
            }
            g.zoom(null, null, t);
          };
        });
  }

  function gesture(that, args, clean) {
    return (!clean && _activeGesture) || new Gesture(that, args);
  }

  function Gesture(that, args) {
    this.that = that;
    this.args = args;
    this.active = 0;
    this.extent = extent.apply(that, args);
  }

  Gesture.prototype = {
    start: function(d3_event) {
      if (++this.active === 1) {
        _activeGesture = this;
        dispatch.call('start', this, d3_event);
      }
      return this;
    },
    zoom: function(d3_event, key, transform) {
      if (this.mouse && key !== 'mouse') this.mouse[1] = transform.invert(this.mouse[0]);
      if (this.pointer0 && key !== 'touch') this.pointer0[1] = transform.invert(this.pointer0[0]);
      if (this.pointer1 && key !== 'touch') this.pointer1[1] = transform.invert(this.pointer1[0]);
      _transform = transform;
      dispatch.call('zoom', this, d3_event, key, transform);
      return this;
    },
    end: function(d3_event) {
      if (--this.active === 0) {
        _activeGesture = null;
        dispatch.call('end', this, d3_event);
      }
      return this;
    }
  };

  function wheeled(d3_event) {
    if (!filter.apply(this, arguments)) return;
    var g = gesture(this, arguments),
        t = _transform,
        k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], t.k * Math.pow(2, wheelDelta.apply(this, arguments)))),
        p = utilFastMouse(this)(d3_event);

    // If the mouse is in the same location as before, reuse it.
    // If there were recent wheel events, reset the wheel idle timeout.
    if (g.wheel) {
      if (g.mouse[0][0] !== p[0] || g.mouse[0][1] !== p[1]) {
        g.mouse[1] = t.invert(g.mouse[0] = p);
      }
      clearTimeout(g.wheel);

    // Otherwise, capture the mouse point and location at the start.
    } else {
      g.mouse = [p, t.invert(p)];
      d3_interrupt(this);
      g.start(d3_event);
    }

    d3_event.preventDefault();
    d3_event.stopImmediatePropagation();
    g.wheel = setTimeout(wheelidled, _wheelDelay);
    g.zoom(d3_event, 'mouse', constrain(translate(scale(t, k), g.mouse[0], g.mouse[1]), g.extent, translateExtent));

    function wheelidled() {
      g.wheel = null;
      g.end(d3_event);
    }
  }

  var _downPointerIDs = new Set();
  var _pointerLocGetter;

  function pointerdown(d3_event) {
    _downPointerIDs.add(d3_event.pointerId);

    if (!filter.apply(this, arguments)) return;

    var g = gesture(this, arguments, _downPointerIDs.size === 1);
    var started;

    d3_event.stopImmediatePropagation();
    _pointerLocGetter = utilFastMouse(this);
    var loc = _pointerLocGetter(d3_event);
    var p = [loc, _transform.invert(loc), d3_event.pointerId];
    if (!g.pointer0) {
       g.pointer0 = p;
       started = true;

    } else if (!g.pointer1 && g.pointer0[2] !== p[2]) {
       g.pointer1 = p;
    }

    if (started) {
      d3_interrupt(this);
      g.start(d3_event);
    }
  }

  function pointermove(d3_event) {
    if (!_downPointerIDs.has(d3_event.pointerId)) return;

    if (!_activeGesture || !_pointerLocGetter) return;

    var g = gesture(this, arguments);

    var isPointer0 = g.pointer0 && g.pointer0[2] === d3_event.pointerId;
    var isPointer1 = !isPointer0 && g.pointer1 && g.pointer1[2] === d3_event.pointerId;

    if ((isPointer0 || isPointer1) && 'buttons' in d3_event && !d3_event.buttons) {
      // The pointer went up without ending the gesture somehow, e.g.
      // a down mouse was moved off the map and released. End it here.
      if (g.pointer0) _downPointerIDs.delete(g.pointer0[2]);
      if (g.pointer1) _downPointerIDs.delete(g.pointer1[2]);
      g.end(d3_event);
      return;
    }

    d3_event.preventDefault();
    d3_event.stopImmediatePropagation();

    var loc = _pointerLocGetter(d3_event);
    var t, p, l;

    if (isPointer0) g.pointer0[0] = loc;
    else if (isPointer1) g.pointer1[0] = loc;

    t = _transform;
    if (g.pointer1) {
      var p0 = g.pointer0[0], l0 = g.pointer0[1],
          p1 = g.pointer1[0], l1 = g.pointer1[1],
          dp = (dp = p1[0] - p0[0]) * dp + (dp = p1[1] - p0[1]) * dp,
          dl = (dl = l1[0] - l0[0]) * dl + (dl = l1[1] - l0[1]) * dl;
      t = scale(t, Math.sqrt(dp / dl));
      p = [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2];
      l = [(l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2];
    } else if (g.pointer0) {
      p = g.pointer0[0];
      l = g.pointer0[1];
    } else {
      return;
    }
    g.zoom(d3_event, 'touch', constrain(translate(t, p, l), g.extent, translateExtent));
  }

  function pointerup(d3_event) {
    if (!_downPointerIDs.has(d3_event.pointerId)) return;

    _downPointerIDs.delete(d3_event.pointerId);

    if (!_activeGesture) return;

    var g = gesture(this, arguments);

    d3_event.stopImmediatePropagation();

    if (g.pointer0 && g.pointer0[2] === d3_event.pointerId) delete g.pointer0;
    else if (g.pointer1 && g.pointer1[2] === d3_event.pointerId) delete g.pointer1;

    if (g.pointer1 && !g.pointer0) {
      g.pointer0 = g.pointer1;
      delete g.pointer1;
    }
    if (g.pointer0) {
      g.pointer0[1] = _transform.invert(g.pointer0[0]);
    } else {
      g.end(d3_event);
    }
  }

  zoom.wheelDelta = function(_) {
    return arguments.length ? (wheelDelta = utilFunctor(+_), zoom) : wheelDelta;
  };

  zoom.filter = function(_) {
    return arguments.length ? (filter = utilFunctor(!!_), zoom) : filter;
  };

  zoom.extent = function(_) {
    return arguments.length ? (extent = utilFunctor([[+_[0][0], +_[0][1]], [+_[1][0], +_[1][1]]]), zoom) : extent;
  };

  zoom.scaleExtent = function(_) {
    return arguments.length ? (scaleExtent[0] = +_[0], scaleExtent[1] = +_[1], zoom) : [scaleExtent[0], scaleExtent[1]];
  };

  zoom.translateExtent = function(_) {
    return arguments.length ? (translateExtent[0][0] = +_[0][0], translateExtent[1][0] = +_[1][0], translateExtent[0][1] = +_[0][1], translateExtent[1][1] = +_[1][1], zoom) : [[translateExtent[0][0], translateExtent[0][1]], [translateExtent[1][0], translateExtent[1][1]]];
  };

  zoom.constrain = function(_) {
    return arguments.length ? (constrain = _, zoom) : constrain;
  };

  zoom.interpolate = function(_) {
    return arguments.length ? (interpolate = _, zoom) : interpolate;
  };

  zoom._transform = function(_) {
    return arguments.length ? (_transform = _, zoom) : _transform;
  };

  return utilRebind(zoom, dispatch, 'on');
}
