import { BigNumber } from 'bignumber.js';
import BN from 'bn.js';
import { isHexString, isNullOrUndefined } from '@metamask/utils';
import { addHexPrefix } from 'ethereumjs-util';
import { EtherDenomination } from '../constants/common';
import { stripHexPrefix } from './hexstring-utils';

export type NumericValue = string | number | BN | BigNumber;
export type NumericBase = 10 | 16;

/**
 * All variations of isHexString from our own utilities and etherumjs-utils
 * return false for a '-' prefixed hex string. This utility method strips the
 * possible '-' from the string before testing its validity so that negative
 * hex values can be properly handled.
 *
 * @param value - The string to check
 * @returns true if the value is a hex string (negative or otherwise)
 */
function isHexStringOrNegatedHexString(value: string): value is string {
  return isHexString(value.replace('-', '')) || isHexString(value);
}

/**
 * BigNumber supports hex strings with '.' (aka decimals) in the string.
 * No version of isHexString returs true if the string contains a decimal so
 * this method is used to check if both parts of the string split by the
 * decimal are hex strings. If so we can feed this value into BigNumber to get
 * a valid Numeric.
 *
 * @param value - The string to check
 * @returns true if the string is a hexadecimal split by '.'
 */
function isDecimalHex(value: string): boolean {
  const parts = value.split('.');
  if (parts.length === 1) {
    return false;
  }
  return parts.every((part) => isHexStringOrNegatedHexString(part));
}

/**
 * Converts a hexadecimal in string or number format to a BigNumber.
 * Note that in many places in our codebase we call 'addHexPrefix' on a negated
 * hexadecimal string resulting in '0x-a' which will fail checks for
 * isHexString. Sometimes we DO not add the 0x so we have to check for '-a'
 * as well.
 *
 * @param value - hexadecimal value in string or number format.
 * @returns A BigNumber representation of the value
 */
function hexadecimalToBigNumber(value: string | number): BigNumber {
  const stringified = typeof value === 'number' ? `${value}` : value;
  const isNegative = stripHexPrefix(stringified)[0] === '-';
  const valueWithoutNegation = stringified.replace('-', '');

  const valueAsBigNumber = new BigNumber(
    stripHexPrefix(valueWithoutNegation),
    16,
  );

  return isNegative ? valueAsBigNumber.negated() : valueAsBigNumber;
}

/**
 * Converts a decimal in string or number format to a BigNumber.
 *
 * @param value - decimal value in string or number format.
 * @returns A BigNumber representation of the value
 */
function decimalToBigNumber(value: string | number) {
  return new BigNumber(String(value), 10);
}

/**
 * This method is used to safely convert a string type value to a BigNumber.
 * The only valid strings for this method are those that are either hexadecimal
 * numeric values OR numeric strings that can be converted to BigNumbers. It is
 * impossible to tell the difference between a hex value of 100000 vs a decimal
 * value of 100000 so a second parameter indicating the numeric base of the
 * string value must be provided.
 *
 * @param value - A hexadecimal or decimal string
 * @param numericBase - Either 16 for a hexadeciaml or 10 for a decimal
 * @returns A BigNumber representation of the value
 */
function stringToBigNumber(value: string, numericBase: NumericBase) {
  if (typeof value !== 'string') {
    throw new Error(
      `Value of type ${typeof value} passed to stringToBigNumber`,
    );
  }
  if (
    numericBase === 16 &&
    (isHexStringOrNegatedHexString(value) || isDecimalHex(value))
  ) {
    return hexadecimalToBigNumber(value);
  } else if (
    numericBase === 10 &&
    // check if we have a finite integer or float
    (isFinite(parseInt(value, 10)) || isFinite(parseFloat(value)))
  ) {
    return decimalToBigNumber(value);
  }
  throw new Error(
    `String provided to stringToBigNumber is not a hexadecimal or decimal string: ${value}, ${numericBase}`,
  );
}

/**
 * This method will convert a hexadecimal or deciaml number into a BigNumber.
 * The second parameter must be supplied and determines whether to treat the
 * value as a hexadecimal or decimal value.
 *
 * @param value - hexadecimal or decimal number[]
 * @param numericBase - 10 for decimal, 16 for hexadecimal
 * @returns BigNumber representation of the value
 */
function numberToBigNumber(value: number, numericBase: NumericBase) {
  if (typeof value !== 'number') {
    throw new Error(
      `Value of type ${typeof value} passed to numberToBigNumber`,
    );
  }
  if (numericBase === 16 && isHexString(`${value}`)) {
    return new BigNumber(`${value}`, 16);
  }
  return new BigNumber(value, 10);
}

/**
 * Method to convert a BN to a BigNumber
 *
 * @param value - A BN representation of a value
 * @returns A BigNumber representation of the BN's underlying value
 */
function bnToBigNumber(value: BN) {
  if (value instanceof BN === false) {
    throw new Error(
      `value passed to bnToBigNumber is not a BN. Received type ${typeof value}`,
    );
  }
  return new BigNumber(value.toString(16), 16);
}

/**
 * Converts a value of the supported types (string, number, BN) to a BigNumber.
 *
 * @param value - The value to convert to a BigNumber
 * @param numericBase - The numeric base of the underlying value
 * @returns A BigNumber representation of the value
 */
function valueToBigNumber(value: string | number, numericBase: NumericBase) {
  if (typeof value === 'string') {
    return stringToBigNumber(value, numericBase);
  } else if (typeof value === 'number' && isNaN(value) === false) {
    return numberToBigNumber(value, numericBase);
  }

  throw new Error(
    `Value: ${value} is not a string, number, BigNumber or BN. Type is: ${typeof value}.`,
  );
}

// Big Number Constants
const BIG_NUMBER_WEI_MULTIPLIER = new BigNumber('1000000000000000000');
const BIG_NUMBER_GWEI_MULTIPLIER = new BigNumber('1000000000');
const BIG_NUMBER_ETH_MULTIPLIER = new BigNumber('1');

const toNormalizedDenomination = {
  WEI: (bigNumber: BigNumber) => bigNumber.div(BIG_NUMBER_WEI_MULTIPLIER),
  GWEI: (bigNumber: BigNumber) => bigNumber.div(BIG_NUMBER_GWEI_MULTIPLIER),
  ETH: (bigNumber: BigNumber) => bigNumber.div(BIG_NUMBER_ETH_MULTIPLIER),
};
const toSpecifiedDenomination = {
  WEI: (bigNumber: BigNumber) =>
    bigNumber.times(BIG_NUMBER_WEI_MULTIPLIER).round(),
  GWEI: (bigNumber: BigNumber) =>
    bigNumber.times(BIG_NUMBER_GWEI_MULTIPLIER).round(9),
  ETH: (bigNumber: BigNumber) =>
    bigNumber.times(BIG_NUMBER_ETH_MULTIPLIER).round(9),
};

/**
 * Gets the value in ETH of the numeric supplied, used in this file only to
 * convert to ETH prior to converting to another denomination. The following
 * quirks were programmed into this method to replicate behavior of the
 * predecessor to Numeric, which was 'conversionUtil'. If a denomination is
 * not supplied, and toDenomination is called, then we assume the denomination
 * was originally ETH, otherwise we convert it to ETH.
 *
 * @param numeric
 * @returns value in ETH
 */
function getValueInETH(numeric: Numeric) {
  if (
    numeric.denomination === EtherDenomination.ETH ||
    typeof numeric.denomination === 'undefined'
  ) {
    return numeric.value;
  }
  return toNormalizedDenomination[numeric.denomination](numeric.value);
}

/**
 * When applying operands to Numerics that have a specified Denomination then
 * we should first convert the provided inputNumeric to the same Denomination
 * as the baseNumeric. There are cases where this doesn't apply:
 *
 * 1. If the denominations are already the same. No conversion is necessary.
 * 2. If the inputNumeric does not have a denomination set. We assume in this
 * case that the value is already in the appropriate denomination.
 *
 * @param baseNumeric
 * @param inputNumeric
 * @returns
 */
function alignOperandDenominations(
  baseNumeric: Numeric,
  inputNumeric: Numeric,
) {
  if (
    typeof inputNumeric.denomination !== 'undefined' &&
    baseNumeric.denomination !== inputNumeric.denomination
  ) {
    return inputNumeric.toDenomination(baseNumeric.denomination);
  }

  return inputNumeric;
}

/**
 * Numeric is a class whose methods will always return a new, not mutated,
 * value. This allows for chaining of non-terminating methods. Previously we
 * had near a hundred helper methods that composed one-another, making tracking
 * through the chain near impossible. This API is designed such that no helper
 * methods should be needed. Take the case of hexWEIToDecGWEI, a helper method
 * for taking a hex string representing a value in WEI and converting that to a
 * decimal of GWEI. Prior to this class the method would call into our root
 * level 'conversionUtil' which was the proverbial kitchen sink doing
 * everything from denomination conversion, currency conversion (with provided
 * conversionRate prop) and more. The same opeartion can now be expressed as:
 * new Numeric(hexString, 16, EtherDenomination.WEI)
 * .toDenomination(EtherDenomination.GWEI)
 * .toBase(10)
 * .toString();
 * This has the benefit of being fairly transparent as you can read each step
 * in the chain and have a good sense of what is being done. It also is highly
 * composable so that we shouldn't need tons of helper methods for shortcuts.
 */
export class Numeric {
  /**
   * The underlying value of the Numeric, always in BigNumber form
   */
  value: BigNumber;

  /**
   * The numeric base for this Numeric, either 10 for decimal or 16 for Hex
   */
  base?: NumericBase;

  /**
   * The current denomination, if any. The only supported denominations are
   * ETH, GWEI, WEI.
   */
  denomination?: EtherDenomination;

  constructor(
    value: NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    this.base = base;
    this.denomination = denomination;
    if (value instanceof BigNumber) {
      this.value = value;
    } else if (value instanceof BN) {
      this.value = bnToBigNumber(value);
    } else if (
      isNullOrUndefined(value) ||
      (typeof value === 'number' && isNaN(value)) ||
      (typeof value === 'string' && value === '')
    ) {
      // There are parts of the codebase that call this method without a value,
      // or with a 'NaN' (which is probably a bug somewhere in our tests?).
      // Over time of converting to TypeScript we will eradicate those, but the
      // helper methods that those instances employ would default the value to
      // 0. This block keeps that intact.
      this.value = new BigNumber('0', 10);
      this.base = 10;
    } else if (base) {
      this.value = valueToBigNumber(value, base);
    } else {
      throw new Error(
        `You must specify the base of the provided number if the value is not already a BigNumber`,
      );
    }
  }

  /**
   * This is a tool used internally to check if a value is already a Numeric
   * and return it if it is, otherwise it uses the other provided arguments to
   * create a new Numeric.
   *
   * @param value - The value of the Numeric
   * @param base - Either undefined, 10 for decimal or 16 for hexadecimal
   * @param denomination - The Ether denomination to set, if any
   */
  static from(
    value: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    if (value instanceof Numeric) {
      if (base || denomination) {
        throw new Error(
          `Numeric.from was called with a value (${value.toString()}) that is already a Numeric but a base and/or denomination was provided. Only supply base or denomination when creating a new Numeric`,
        );
      }
      return value;
    }
    return new Numeric(value, base, denomination);
  }

  /** Conversions */

  /**
   * Returns a new Numeric with the base value changed to the provided base,
   * or the original Numeric if the base provided is the same as the current
   * base. No computation or conversion happens here but rather the result of
   * toString will be changed depending on the value of this.base when that
   * method is invoked.
   *
   * @param base - The numeric base to change the Numeric to, either 10 or 16
   * @returns A new Numeric with the base updated
   */
  toBase(base: NumericBase) {
    if (this.base !== base) {
      return new Numeric(this.value, base, this.denomination);
    }
    return this;
  }

  /**
   * Converts the value to the specified denomination. The following quirks of
   * the predecessor to Numeric, 'conversionUtil', were programmed into this
   * method:
   * 1. You may supply a denomination that is undefined, which will result in
   * nothing happening. Coincidently this is also useful due to the nature of
   * chaining operations on Numeric. You may pass an undefined value in this
   * method without breaking the chain to conditionally apply a operator.
   * 2. If the numeric that .toDenomination is called on does not have a
   * denomination set, that is it was constructed without the third parameter,
   * then it is assumed to be in ETH. Otherwise we convert it to ETH prior to
   * attempting to convert it to another denomination because all of the
   * toSpecifiedDenomination methods assume a value in ETH is passed.
   *
   * @param denomination - The denomination to convert to
   * @returns A new numeric with the same base as the previous, but the
   * value and denomination changed accordingly
   */
  toDenomination(denomination?: EtherDenomination) {
    if (denomination && this.denomination !== denomination) {
      const result = new Numeric(
        toSpecifiedDenomination[denomination](getValueInETH(this)),
        this.base,
        denomination,
      );
      return result;
    }
    return this;
  }

  /**
   * Replicates a method of BigNumber that is not in the version of BigNumber
   * that we use currently. Essentially shifting the decimal point backwards by
   * an amount equal to the positive number supplied to the decimals operator.
   * For example, calling shiftedBy(10) on the value 10000000000 will result in
   * a value of 1.0000000000. If passing a negative number, then the decimal
   * position will move forward. 1.0000000000 shiftedBy(-10) yields 10000000000
   *
   * @param decimals - The number of decimal places to move. Positive moves
   * decimal backwards, creating a smaller number. Negative values move the
   * decimal forwards, creating a larger number.
   * @returns A new numeric with the same base and denomination as the current
   * but with a new value.
   */
  shiftedBy(decimals: number) {
    const powerOf = new Numeric(Math.pow(10, decimals), 10);
    return this.divide(powerOf);
  }

  /**
   * Applies a conversion rate to the Numeric. If rate is undefined returns the
   * same instance that was operated on. Allowing an undefined value makes
   * chaining this operator feasible with undefined values from the user or
   * state without manipulating the number. For example:
   *
   * new Numeric(5, 10)
   * .applyConversionRate(possiblyUndefinedRate)
   * .toBase(16)
   * .toString();
   *
   * Will return a valid result as long as possiblyUndefinedRate is undefined,
   * a BigNumber or a number. In some areas of the codebase we check to see if
   * the target currency is different from the current currency before applying
   * a conversionRate. This functionality is not built into Numeric and will
   * require breaking the chain before calling this method:
   * let value = new Numeric(5, 10);
   *
   * if (fromCurrency !== toCurrency) {
   * value = value.applyConversionRate(possiblyUndefinedRate);
   * }
   *
   * return value.toBase(16).toString();
   *
   * @param rate - The multiplier to apply
   * @param invert - if true, inverts the rate
   * @returns New Numeric value with conversion rate applied.
   */
  applyConversionRate(rate?: number | BigNumber, invert?: boolean) {
    if (typeof rate === 'undefined') {
      return this;
    }

    let conversionRate = new Numeric(rate, 10);
    if (invert) {
      conversionRate = new Numeric(new BigNumber(1.0)).divide(conversionRate);
    }
    return this.times(conversionRate);
  }

  round(
    numberOfDecimals?: number,
    roundingMode: number = BigNumber.ROUND_HALF_DOWN,
  ) {
    if (typeof numberOfDecimals === 'number') {
      return new Numeric(
        this.value.round(numberOfDecimals, roundingMode),
        this.base,
        this.denomination,
      );
    }
    return this;
  }

  /**
   * TODO: make it possible to add ETH + GWEI value. So if you have
   * Numeric 1 with denomination ETH and Numeric 2 with Denomination WEI,
   * first convert Numeric 2 to ETH then add the amount to Numeric 1.
   *
   * @param value
   * @param base
   * @param denomination
   */
  add(
    value: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    const numeric = Numeric.from(value, base, denomination);
    return new Numeric(
      this.value.add(alignOperandDenominations(this, numeric).value),
      this.base,
      this.denomination,
    );
  }

  /**
   * TODO: make it possible to subtract ETH - GWEI value. So if you have
   * Numeric 1 with denomination ETH and Numeric 2 with Denomination WEI,
   * first convert Numeric 2 to ETH then subtract the amount from Numeric 1.
   *
   * @param value
   * @param base
   * @param denomination
   */
  minus(
    value: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    const numeric = Numeric.from(value, base, denomination);

    return new Numeric(
      this.value.minus(alignOperandDenominations(this, numeric).value),
      this.base,
      this.denomination,
    );
  }

  times(
    multiplier: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    const multiplierNumeric = Numeric.from(multiplier, base, denomination);
    return new Numeric(
      this.value.times(
        alignOperandDenominations(this, multiplierNumeric).value,
      ),
      this.base,
      this.denomination,
    );
  }

  /**
   * Divides the Numeric by another supplied Numeric, carrying over the base
   * and denomination from the current Numeric.
   *
   * @param divisor - The Numeric to divide this Numeric by
   * @param base
   * @param denomination
   * @returns A new Numeric that contains the result of the division
   */
  divide(
    divisor: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    return new Numeric(
      this.value.div(
        alignOperandDenominations(
          this,
          Numeric.from(divisor, base, denomination),
        ).value,
      ),
      this.base,
      this.denomination,
    );
  }

  greaterThan(
    comparator: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    return this.value.greaterThan(
      Numeric.from(comparator, base, denomination).value,
    );
  }

  greaterThanOrEqualTo(
    comparator: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    return this.value.greaterThanOrEqualTo(
      Numeric.from(comparator, base, denomination).value,
    );
  }

  lessThan(
    comparator: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    return this.value.lessThan(
      Numeric.from(comparator, base, denomination).value,
    );
  }

  lessThanOrEqualTo(
    comparator: Numeric | NumericValue,
    base?: NumericBase,
    denomination?: EtherDenomination,
  ) {
    return this.value.lessThanOrEqualTo(
      Numeric.from(comparator, base, denomination).value,
    );
  }

  isNegative() {
    return this.value.isNegative();
  }

  isPositive() {
    return this.isNegative() === false;
  }

  /**
   * Get a base 16 hexadecimal string representation of the Numeric that is
   * 0x prefixed. This operation bypasses the currently set base of the
   * Numeric.
   *
   * @returns 0x prefixed hexstring.
   */
  toPrefixedHexString() {
    return addHexPrefix(this.value.toString(16));
  }

  /**
   * Gets the string representation of the Numeric, using the current value of
   * this.base to determine if it should be a decimal or hexadecimal string.
   *
   * @returns the string representation of the Numeric
   */
  toString() {
    return this.value.toString(this.base);
  }

  /**
   * Returns a fixed-point decimal string representation of the Numeric
   *
   * @param decimals - the amount of decimal precision to use when rounding
   * @returns A fixed point decimal string represenation of the Numeric
   */
  toFixed(decimals: number) {
    return this.value.toFixed(decimals);
  }

  /**
   * Converts the value to a JavaScript Number, with all of the inaccuracy that
   * could come with that.
   *
   * @returns The value as a JS Number
   */
  toNumber() {
    return this.value.toNumber();
  }
}
