import { BigNumber, ethers } from 'ethers';
import BigNumberJS from 'bignumber.js';
import { NFTStandard, PaymentToken } from './types';
import { NUM_BITS_IN_BYTE, WEI_DECIMAL } from './consts';

// consts that predominantly pertain to this file
const BITSIZE_MAX_VALUE = 32;

/**
 * hexchar is 0 to 15 which is 2 ** 4 - 1.
 * This means that hexchar (aka nibble) is half a byte,
 * since byte is 8 bits. This function converts number
 * of bytes to number of nibbles.
 *
 * e.g. 2 bytes is 4 nibbles
 *
 * @param byteCount
 * @returns number of nibbles that represent the byteCount bytes
 */
export const bytesToNibbles = (byteCount: number) => {
  if (typeof byteCount != 'number') throw new Error('only numbers supported');
  if (byteCount < 1) throw new Error('invalid byteCount');
  return byteCount * 2;
};

/**
 * (21.42, 32) -> 0x0015002A
 *
 * (1.2, 32)   -> 0x00010002
 *
 * Notice how the whole decimal part is reprsented by the first 4 nibbles,
 * whereas the decimal part is represented by the second part, i.e. the
 * last 4 nibbles
 *
 * @param number
 * @param bitsize
 * @returns number's padded (of bitsize total length) hex format
 */
export const toPaddedHex = (number: number, bitsize: number) => {
  // in node.js this function fails for bitsize above 32 bits
  if (bitsize > BITSIZE_MAX_VALUE)
    throw new Error(
      `bitsize ${bitsize} above maximum value ${BITSIZE_MAX_VALUE}`
    );
  // conversion to unsigned form based on
  if (number < 0) throw new Error('unsigned number not supported');

  // 8 bits = 1 byteCount; 16 bits = 2 byteCount, ...
  const byteCount = Math.ceil(bitsize / NUM_BITS_IN_BYTE);

  // shifting 0 bits removes decimals
  // toString(16) converts into hex
  // .padStart(byteCount * 2, "0") adds byte
  return (
    '0x' +
    (number >>> 0)
      .toString(16)
      .toUpperCase()
      // 1 nibble = 4 bits. 1 byte = 2 nibbles
      .padStart(bytesToNibbles(byteCount), '0')
  );
};

const sameLength = <T>(a: T[], b: T[]) => a.length === b.length;

const validateSameLength = (...args: any[]) => {
  let prev: any = args[0];
  for (const curr of args) {
    if (!curr) continue;
    if (!sameLength(prev, curr)) throw new Error('args length variable');
    prev = curr;
  }
  return true;
};

// const decimalToPaddedHexString = (number: number, bitsize: number): string => {
//   const byteCount = Math.ceil(bitsize / 8);
//   const maxBinValue = Math.pow(2, bitsize) - 1;
//   if (bitsize > 32) throw 'number above maximum value';
//   if (number < 0) number = maxBinValue + number + 1;
//   return (
//     '0x' +
//     (number >>> 0)
//       .toString(16)
//       .toUpperCase()
//       .padStart(byteCount * 2, '0')
//   );
// };

type IObjectKeysValues =
  | string[]
  | BigNumber[]
  | boolean[]
  | number[]
  | PaymentToken[]
  | string[][]
  | string[][][]
  | number[][]
  | [string, number][];

interface IObjectKeys {
  [key: string]: IObjectKeysValues | undefined;
}

interface PrepareBatch extends IObjectKeys {
  nftStandards: NFTStandard[];
  nftAddresses: string[];
  tokenIds: BigNumber[];
  lendAmounts?: BigNumber[];
  rentAmounts?: BigNumber[];
  maxRentDurations?: number[];
  minRentDurations?: number[];
  dailyRentPrices?: string[];
  collateralPrices?: string[];
  paymentOptions?: PaymentToken[];
  rentDurations?: number[];
  lendingIds?: BigNumber[];
  rentingIds?: BigNumber[];
  allowedRenters?: string[][][];
}

interface PrepareRevenueShareBatch extends IObjectKeys {
  nftStandards?: NFTStandard[];
  nftAddresses: string[];
  tokenIds: BigNumber[];
  lendAmounts?: BigNumber[];
  rentAmounts?: BigNumber[];
  maxRentDurations?: number[];
  paymentOptions?: PaymentToken[];
  rentDurations?: number[];
  lendingIds?: BigNumber[];
  rentingIds?: BigNumber[];
  upfrontFee?: string[];
  revenueShareInfo?: string[][] | number[][];
  allowedRenters?: string[][];
  revenueAmounts?: BigNumber[];
  renters?: string[];
  revenueTokenAddress?: string[];
}

/**
 * To spend as little gas as possible, arguments must follow a particular format
 * when passed to the contract. This function prepares whatever inputs you want
 * to send, and returns the inputs in an optimal format.
 *
 * This algorithm's time complexity is pretty awful. But, it will never run on
 * large arrays, so it doesn't really matter.
 * @param args
 */
export const prepareBatch = (args: PrepareBatch) => {
  if (args.nftAddresses.length === 1) return args;
  validateSameLength(Object.values(args));
  let nfts: Map<string, PrepareBatch> = new Map();
  const pb: PrepareBatch = { nftAddresses: [], tokenIds: [], nftStandards: [] };

  // O(N), maybe higher because of [...o[k]!, v[i]]
  const updateNfts = (nftAddresses: string, i: number) => {
    const o = nfts.get(nftAddresses);
    for (const [k, v] of Object.entries(args)) {
      if (!o) throw new Error(`could not find ${nftAddresses}`);
      if (v) o[k] = [...(o[k] ?? []), v[i]] as IObjectKeysValues;
    }
    return nfts;
  };

  const createNft = (nftAddresses: string, i: number) => {
    nfts.set(nftAddresses, {
      nftStandards: [args.nftStandards[i]],
      nftAddresses: [nftAddresses],
      tokenIds: [args.tokenIds[i]],
      lendAmounts: args.lendAmounts ? [args.lendAmounts[i]] : undefined,
      rentAmounts: args.rentAmounts ? [args.rentAmounts[i]] : undefined,
      maxRentDurations: args.maxRentDurations
        ? [args.maxRentDurations[i]]
        : undefined,
      minRentDurations: args.minRentDurations
        ? [args.minRentDurations[i]]
        : undefined,
      dailyRentPrices: args.dailyRentPrices
        ? [args.dailyRentPrices[i]]
        : undefined,
      collateralPrices: args.collateralPrices
        ? [args.collateralPrices[i]]
        : undefined,
      paymentOptions: args.paymentOptions
        ? [args.paymentOptions[i]]
        : undefined,
      rentDurations: args.rentDurations ? [args.rentDurations[i]] : undefined,
      lendingIds: args.lendingIds ? [args.lendingIds[i]] : undefined,
      rentingIds: args.rentingIds ? [args.rentingIds[i]] : undefined,
      allowedRenters: args.allowedRenters
        ? [args.allowedRenters[i]]
        : undefined,
    });
    return nfts;
  };

  // O(2 * N), yikes to 2
  const worstArgsort = (tokenIds: BigNumber[]) => {
    var indices = new Array(tokenIds.length);
    for (var i = 0; i < tokenIds.length; ++i) indices[i] = i;
    indices.sort((a, b) =>
      tokenIds[a].lt(tokenIds[b]) ? -1 : tokenIds[a].gt(tokenIds[b]) ? 1 : 0
    );
    return {
      sortedTokenID: sortPerIndices(indices, tokenIds),
      argsort: indices,
    };
  };

  const sortPerIndices = (argsort: number[], arr: any[]) =>
    argsort.map(i => arr[i]);

  // O(N ** M). for each nft loop through all args. M - number of args
  Object.values(args.nftAddresses).forEach((nft, i) => {
    if (nfts.has(nft)) nfts = updateNfts(nft, i);
    else nfts = createNft(nft, i);
  });

  const iterator = nfts.keys();
  // O(N * N)
  while (iterator) {
    const g = iterator.next().value;
    if (!g) break; // end of loop

    const nft = nfts.get(g) as PrepareBatch;
    const tokenIds = nft.tokenIds as BigNumber[];
    const { argsort } = worstArgsort(tokenIds);

    for (const k of Object.keys(nft)) {
      if (!nft[k]) continue;
      const sorted = sortPerIndices(argsort, nft[k] ?? []) as IObjectKeysValues;
      pb[k] = [...(pb[k] ?? []), ...sorted] as IObjectKeysValues;
    }
  }

  return pb;
};

export const prepareRevenueShareBatch = (args: PrepareRevenueShareBatch) => {
  if (args.nftAddresses.length === 1) return args;
  validateSameLength(Object.values(args));
  let nfts: Map<string, PrepareRevenueShareBatch> = new Map();
  const pb: PrepareRevenueShareBatch = {
    nftAddresses: [],
    tokenIds: [],
    nftStandards: [],
  };

  // O(N), maybe higher because of [...o[k]!, v[i]]
  const updateNfts = (nftAddresses: string, i: number) => {
    const o = nfts.get(nftAddresses);
    for (const [k, v] of Object.entries(args)) {
      if (!o) throw new Error(`could not find ${nftAddresses}`);
      if (v) o[k] = [...(o[k] ?? []), v[i]] as IObjectKeysValues;
    }
    return nfts;
  };

  const createNft = (nftAddresses: string, i: number) => {
    nfts.set(nftAddresses, {
      nftStandards: args.nftStandards ? [args.nftStandards[i]] : undefined,
      nftAddresses: [nftAddresses],
      tokenIds: [args.tokenIds[i]],
      lendAmounts: args.lendAmounts ? [args.lendAmounts[i]] : undefined,
      rentAmounts: args.rentAmounts ? [args.rentAmounts[i]] : undefined,
      maxRentDurations: args.maxRentDurations
        ? [args.maxRentDurations[i]]
        : undefined,
      paymentOptions: args.paymentOptions
        ? [args.paymentOptions[i]]
        : undefined,
      rentDurations: args.rentDurations ? [args.rentDurations[i]] : undefined,
      lendingIds: args.lendingIds ? [args.lendingIds[i]] : undefined,
      rentingIds: args.rentingIds ? [args.rentingIds[i]] : undefined,
    });
    return nfts;
  };

  // O(2 * N), yikes to 2
  const worstArgsort = (tokenIds: BigNumber[]) => {
    var indices = new Array(tokenIds.length);
    for (var i = 0; i < tokenIds.length; ++i) indices[i] = i;
    indices.sort((a, b) =>
      tokenIds[a].lt(tokenIds[b]) ? -1 : tokenIds[a].gt(tokenIds[b]) ? 1 : 0
    );
    return {
      sortedTokenID: sortPerIndices(indices, tokenIds),
      argsort: indices,
    };
  };

  const sortPerIndices = (argsort: number[], arr: any[]) =>
    argsort.map(i => arr[i]);

  // O(N ** M). for each nft loop through all args. M - number of args
  Object.values(args.nftAddresses).forEach((nft, i) => {
    if (nfts.has(nft)) nfts = updateNfts(nft, i);
    else nfts = createNft(nft, i);
  });

  const iterator = nfts.keys();
  // O(N * N)
  while (iterator) {
    const g = iterator.next().value;
    if (!g) break; // end of loop

    const nft = nfts.get(g) as PrepareBatch;
    const tokenIds = nft.tokenIds as BigNumber[];
    const { argsort } = worstArgsort(tokenIds);

    for (const k of Object.keys(nft)) {
      if (!nft[k]) continue;
      const sorted = sortPerIndices(argsort, nft[k] ?? []) as IObjectKeysValues;
      pb[k] = [...(pb[k] ?? []), ...sorted] as IObjectKeysValues;
    }
  }

  return pb;
};

// Convert a number into specific byte string
export const convertToSpecificByteString = (
  number: number,
  byteSize: number
) => {
  return ethers.utils.hexZeroPad(ethers.utils.hexlify(number), byteSize);
};

// Ex. : 3 to 0x00000003
export const numberToByte4 = (number: number) => {
  return convertToSpecificByteString(number, 4);
};

export const numberToByte8 = (number: number) => {
  return convertToSpecificByteString(number, 8);
};

export const numberToByte16 = (number: number) => {
  return convertToSpecificByteString(number, 16);
};

export const numberToByte32 = (number: number) => {
  return convertToSpecificByteString(number, 32);
};

// Convert Number to Byte4 string
// Ex. :  0x00000003 to 3
export const byteToNumber = (number: string) => {
  return parseInt(number);
};

export const bigNumberToWei = (
  amount: string | number,
  decimal: string | number = WEI_DECIMAL
) => {
  return new BigNumberJS(amount).multipliedBy(
    new BigNumberJS(10).pow(Number(decimal))
  );
};

export const bigNumberToEther = (
  amount: string | number,
  decimal: string | number = WEI_DECIMAL
) => {
  return new BigNumberJS(amount).div(new BigNumberJS(10).pow(Number(decimal)));
};
