import isPlainObject from "lodash/isPlainObject";
import orderBy from "lodash/orderBy";

import { interpolate } from "victory-vendor/d3-interpolate";

export const isInterpolatable = function (obj) {
  // d3 turns null into 0 and undefined into NaN, which we don't want.
  if (obj !== null) {
    switch (typeof obj) {
      case "undefined":
        return false;
      case "number":
        // The standard `isNaN` is fine in this case since we already know the
        // type is number.
        return (
          !isNaN(obj) &&
          obj !== Number.POSITIVE_INFINITY &&
          obj !== Number.NEGATIVE_INFINITY
        );
      case "string":
        // d3 might not *actually* be able to interpolate the string, but it
        // won't cause any issues to let it try.
        return true;
      case "boolean":
        // d3 turns Booleans into integers, which we don't want. Sure, we could
        // interpolate from 0 -> 1, but we'd be sending a non-Boolean to
        // something expecting a Boolean.
        return false;
      case "object":
        // Don't try to interpolate class instances (except Date or Array).
        return obj instanceof Date || Array.isArray(obj) || isPlainObject(obj);
      case "function":
        // Careful! There may be extra properties on function objects that the
        // component expects to access - for instance, it may be a `d3.scale()`
        // function, which has its own methods attached. We don't know if the
        // component is only going to call the function (in which case it's
        // safely interpolatable) or if it's going to access special properties
        // (in which case our function generated from `interpolateFunction` will
        // most likely cause an error). We could check for enumerable properties
        // on the function object here to see if it's a "plain" function, but
        // let's just require that components prevent such function props from
        // being animated in the first place.
        return true;
    }
  }
  return false;
};

/**
 * Interpolate immediately to the end value at the given step `when`.
 * Some nicer default behavior might be to jump at the halfway point or return
 * `a` if `t` is 0 (instead of always returning `b`). But d3's default
 * interpolator does not do these things:
 *
 *   d3.interpolate('aaa', 'bbb')(0) === 'bbb'
 *
 * ...and things might get wonky if we don't replicate that behavior.
 *
 * @param {any} a - Start value.
 * @param {any} b - End value.
 * @param {Number} when - Step value (0 to 1) at which to jump to `b`.
 * @returns {Function} An interpolation function.
 */
export const interpolateImmediate = function (a, b, when = 0) {
  return function (t) {
    return t < when ? a : b;
  };
};

/**
 * Interpolate to or from a function. The interpolated value will be a function
 * that calls `a` (if it's a function) and `b` (if it's a function) and calls
 * `d3.interpolate` on the resulting values. Note that our function won't
 * necessarily be called (that's up to the component this eventually gets
 * passed to) - but if it does get called, it will return an appropriately
 * interpolated value.
 *
 * @param {any} a - Start value.
 * @param {any} b - End value.
 * @returns {Function} An interpolation function.
 */
export const interpolateFunction = function (a, b) {
  return function (t) {
    if (t >= 1) {
      return b;
    }
    return function (this: unknown) {
      /* eslint-disable prefer-rest-params */
      const aval = typeof a === "function" ? a.apply(this, arguments) : a;
      const bval = typeof b === "function" ? b.apply(this, arguments) : b;
      return interpolate(aval, bval)(t);
    };
  };
};

/**
 * Interpolate to or from an object. This method is a modification of the object interpolator in
 * d3-interpolate https://github.com/d3/d3-interpolate/blob/master/src/object.js. This interpolator
 * differs in that it uses our custom interpolators when interpolating the value of each property in
 * an object. This allows the correct interpolation of nested objects, including styles
 *
 * @param {any} startValue - Start value.
 * @param {any} endValue - End value.
 * @returns {Function} An interpolation function.
 */
export const interpolateObject = function (startValue, endValue) {
  const interpolateTypes = (x, y) => {
    if (x === y || !isInterpolatable(x) || !isInterpolatable(y)) {
      return interpolateImmediate(x, y);
    }
    if (typeof x === "function" || typeof y === "function") {
      return interpolateFunction(x, y);
    }
    if (
      (typeof x === "object" && isPlainObject(x)) ||
      (typeof y === "object" && isPlainObject(y))
    ) {
      return interpolateObject(x, y);
    }
    return interpolate(x, y);
  };

  // When the value is an array, attempt to sort by "key" so that animating nodes may be identified
  // based on "key" instead of index
  const keyData = (val) => {
    return Array.isArray(val) ? orderBy(val, "key") : val;
  };

  const i = {};
  const c = {};
  let a = startValue;
  let b = endValue;
  let k;

  if (a === null || typeof a !== "object") {
    a = {};
  }
  if (b === null || typeof b !== "object") {
    b = {};
  }

  for (k in b) {
    if (k in a) {
      i[k] = interpolateTypes(keyData(a[k]), keyData(b[k]));
    } else {
      c[k] = b[k];
    }
  }

  return function (t) {
    for (k in i) {
      c[k] = i[k](t);
    }
    return c;
  };
};

export const interpolateString = function (a, b) {
  const format = (val) => {
    return typeof val === "string" ? val.replace(/,/g, "") : val;
  };

  return interpolate(format(a), format(b));
};

/**
 * By default, the list of interpolators used by `d3.interpolate` has a few
 * downsides:
 *
 * - `null` values get turned into 0.
 * - `undefined`, `function`, and some other value types get turned into NaN.
 * - Boolean types get turned into numbers, which probably will be meaningless
 *   to whatever is consuming them.
 * - It tries to interpolate between identical start and end values, doing
 *   unnecessary calculations that sometimes result in floating point rounding
 *   errors.
 *
 * If only the default interpolators are used, `VictoryAnimation` will happily
 * pass down NaN (and other bad) values as props to the wrapped component.
 * The component will then either use the incorrect values or complain that it
 * was passed props of the incorrect type. This custom interpolator is added
 * using the `d3.interpolators` API, and prevents such cases from happening
 * for most values.
 *
 * @param {any} a - Start value.
 * @param {any} b - End value.
 * @returns {Function|undefined} An interpolation function, if necessary.
 */
export const victoryInterpolator = function <T>(a: T, b: T): (t: number) => T {
  // If the values are strictly equal, or either value is not interpolatable,
  // just use either the start value `a` or end value `b` at every step, as
  // there is no reasonable in-between value.
  if (a === b || !isInterpolatable(a) || !isInterpolatable(b)) {
    return interpolateImmediate(a, b);
  }
  if (typeof a === "function" || typeof b === "function") {
    return interpolateFunction(a, b);
  }
  if (isPlainObject(a) || isPlainObject(b)) {
    // @ts-expect-error These generics are tough, but they work :)
    return interpolateObject(a, b);
  }
  if (typeof a === "string" || typeof b === "string") {
    return interpolateString(a, b);
  }
  // @ts-expect-error These generics are tough, but they work :)
  return interpolate(a, b);
};
