/* eslint-disable prettier/prettier */
/* global artifacts, web3, contract */
require('chai').use(require('bn-chai')(web3.utils.BN)).use(require('chai-as-promised')).should()
const fs = require('fs')

const { toBN } = require('web3-utils')
const Web3Utils = require('web3-utils')
const { takeSnapshot, revertSnapshot } = require('../scripts/ganacheHelper')
const Storage = artifacts.require('./Storage.sol')
const Shifter = artifacts.require('./XFTanon.sol')
const BadRecipient = artifacts.require('./BadRecipient.sol')
const XFT = artifacts.require('./XFTMock.sol')
const XFTOLDMock = artifacts.require('./XFTOLDMock.sol')
const Token = artifacts.require('anonUSD')
const SwapRouter = artifacts.require('./ISwapRouter.sol')
const UniV3Pool = artifacts.require('./IUniswapV3Pool.sol')
const ERC20 = artifacts.require('./IERC20.sol')
const TokenSwap = artifacts.require('./TokenSwap.sol')
const Oracle = artifacts.require('./Oracle.sol')
const { ETH_AMOUNT, MERKLE_TREE_HEIGHT, FEE_AMOUNT, tokenStyle, weth9, uniswap }
  = require('../config.json')
const burnerRole = Web3Utils.keccak256("BURNER_ROLE")
const minterRole = Web3Utils.keccak256("MINTER_ROLE")
const websnarkUtils = require('websnark/src/utils')
const buildGroth16 = require('websnark/src/groth16')
const stringifyBigInts = require('websnark/tools/stringifybigint').stringifyBigInts
const snarkjs = require('snarkjs')
const bigInt = snarkjs.bigInt
const crypto = require('crypto')
const subtle = crypto.subtle
const circomlib = require('circomlib')
const MerkleTree = require('fixed-merkle-tree')
const { use } = require('chai')
const relayerConfig = require('../../relayer/config.json')
const nonEOAConfig = require ('../../noneoa-relayer/config.json')
require = require('esm')(module)
let allShifters = require('../shifters.ts').shifters
let storagePassword = web3.utils.keccak256('nowledgeIsPower');
let storageHash = web3.utils.keccak256(storagePassword);
let xcrypt;
const cardinalityLaunch = 10 //How many observations to save in a pool, at laumch

const rbigint = (nbytes) => snarkjs.bigInt.leBuff2int(crypto.randomBytes(nbytes))
const pedersenHash = (data) => circomlib.babyJub.unpackPoint(circomlib.pedersenHash.hash(data))[0]
const toFixedHex = (number, length = 32) =>
  '0x' +
  bigInt(number)
    .toString(16)
    .padStart(length * 2, '0')
const getRandomRecipient = () => rbigint(20)

const unlockAccount = async (address) => {
  let provider = web3.currentProvider;
  return new Promise((res, rej) => {
    provider.send({
      method: 'evm_addAccount',
      params: [address, ""]
    }, (d) => {
      provider.send({
        method: 'personal_unlockAccount',
        params: [address, ""]
      }, (d) => {
        res(address);
      });
    });
  });
}

let encryptNote;

function generateDeposit() {
  let deposit = {
    secret: rbigint(31),
    nullifier: rbigint(31),
  }
  const preimage = Buffer.concat([deposit.nullifier.leInt2Buff(31), deposit.secret.leInt2Buff(31)])
  deposit.commitment = pedersenHash(preimage)
  return deposit
}

advanceTimeAndBlock = async (time) => {
  await advanceTime(time);
  await advanceBlock();

  return Promise.resolve(web3.eth.getBlock('latest'));
}

advanceTime = (time) => {
  return new Promise((resolve, reject) => {
      web3.currentProvider.send({
          jsonrpc: "2.0",
          method: "evm_increaseTime",
          params: [time],
          id: new Date().getTime()
      }, (err, result) => {
          if (err) { return reject(err); }
          return resolve(result);
      });
  });
}

advanceBlock = () => {
  return new Promise((resolve, reject) => {
      web3.currentProvider.send({
          jsonrpc: "2.0",
          method: "evm_mine",
          id: new Date().getTime()
      }, (err, result) => {
          if (err) { return reject(err); }
          const newBlockHash = web3.eth.getBlock('latest').hash;

          return resolve(newBlockHash)
      });
  });
}

contract('XFTShifter', (accounts) => {
  let shifter, shifterBTC, shifterETH
  let xft
  let xftOld
  let token
  let storage
  let badRecipient
  const sender = accounts[0]
  const operator = accounts[0]
  const levels = MERKLE_TREE_HEIGHT || 16
  let tokenDenomination = tokenStyle.anonUSD.denoms[0] + "000000000000000000" || '1000000000000000000' // 1 ether
  let uniFee = 3000
  let snapshotId
  let tree
  const fee = FEE_AMOUNT || '1' /* in aUSD */
  const refund = ETH_AMOUNT || '1000000000000000000' // 1 ether
  let recipient = getRandomRecipient()
  const relayer = accounts[1]
  const relayerNonEOA = accounts[2]
  let groth16
  let circuit
  let proving_key

  before(async () => {
    tree = new MerkleTree(levels)
    USDtree = new MerkleTree(levels)
    shifter = await Shifter.at(allShifters['anonUSD500'].shifter) // Use a specific shifter
    shifterETH = await Shifter.at(allShifters['anonETH1'].shifter)
    shifterBTC = await Shifter.at(allShifters['anonBTC1'].shifter)
    storage = await Storage.deployed()
    oracle = await Oracle.deployed()
    xftOld = await XFTOLDMock.deployed()
    xcrypt = async (data, password) => {
      // IV is 0 for the test, but frontend uses the deposit index
      const iv = (await subtle.digest('SHA-256', (password + (await storage.getDepositsLength(shifter.address, sender, storageHash))))).slice(0, 16);
      const dataBuffer = new Uint8Array(Buffer.from(data.slice(2), 'hex'));
      const keyBuffer = new Uint8Array(Buffer.from(password.slice(2), 'hex'));
      const key = await subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt']);

      try {
        let encrypted = await subtle.encrypt({ name: 'AES-GCM', iv }, key, dataBuffer);
        return "0x" + Buffer.from(encrypted).toString('hex').slice(0, 124);
      } catch (err) {
        console.log(err)
        throw new Error("The data provided couldn't be encrypted or decrypted, please check the inputs");
      }
    }
    encryptNote = async (deposit, key) => await xcrypt(`0x${deposit.nullifier.toString(16).padStart(62, '0')}${deposit.secret.toString(16).padStart(62, '0')}`, key);
    xft = await XFT.at(allShifters['XFT'].contract)
    token = await Token.at(allShifters['anonUSD500'].contract)
    tokenETH = await Token.at(allShifters['anonETH1'].contract)
    tokenBTC = await Token.at(allShifters['anonBTC1'].contract)
    weth = await ERC20.at(weth9)

    let shifterPoolETH = await shifterETH.tokenPool()
    let shifterPoolUSD = await shifter.tokenPool()
    let shifterPoolBTC = await shifterBTC.tokenPool()
    let poolETH = await UniV3Pool.at(shifterPoolETH)
    let poolUSD = await UniV3Pool.at(shifterPoolUSD)
    let poolBTC = await UniV3Pool.at(shifterPoolBTC)
    await poolETH.increaseObservationCardinalityNext.sendTransaction(cardinalityLaunch, {from: sender})
    await poolUSD.increaseObservationCardinalityNext.sendTransaction(cardinalityLaunch, {from: sender})
    await poolBTC.increaseObservationCardinalityNext.sendTransaction(cardinalityLaunch, {from: sender})

    badRecipient = await BadRecipient.new()
    groth16 = await buildGroth16()
    circuit = require('../build/circuits/withdraw.json')
    proving_key = fs.readFileSync('build/circuits/withdraw_proving_key.bin').buffer

    // Give the shifter roles
    await xft.grantRole(burnerRole, shifter.address)
    await xft.grantRole(burnerRole, shifterETH.address)
    await token.grantRole(minterRole, shifter.address)
    await tokenETH.grantRole(minterRole, shifterETH.address)

    // Give sender roles
    await xft.grantRole(minterRole, sender)
    await token.grantRole(minterRole, sender)
    await tokenETH.grantRole(minterRole, sender)

    // Snapshot here
    snapshotId = await takeSnapshot()
  })

  describe('#constructor', () => {
    it('should initialize', async () => {
      const tokenFromContract = await shifter.xft()
      tokenFromContract.should.be.equal(xft.address)
    })
  })

  describe('#grantRole Burner', () => {
    it('should work', async () => {
      // Checks if contract has role granted already and revokes role if true
      let hasRole = await xft.hasRole(burnerRole, shifter.address)
      if (hasRole) {
        await xft.revokeRole(burnerRole, shifter.address)
      }

      let { logs } = await xft.grantRole(burnerRole, shifter.address)
      logs[0].address.should.be.equal(xft.address)
      logs[0].event.should.be.equal('RoleGranted')
      logs[0].args.role.should.be.equal(Web3Utils.keccak256("BURNER_ROLE"))
      logs[0].args.account.should.be.equal(shifter.address)
    })
  })

  describe('#grantRole Minter', () => {
    it('should work', async () => {
      // Checks if contract has role granted already and revokes role if true
      let hasRole = await token.hasRole(minterRole, shifter.address)
      if (hasRole) {
        await token.revokeRole(minterRole, shifter.address)
      }

      let { logs } = await token.grantRole(minterRole, shifter.address)
      logs[0].address.should.be.equal(token.address)
      logs[0].event.should.be.equal('RoleGranted')
      logs[0].args.role.should.be.equal(Web3Utils.keccak256("MINTER_ROLE"))
      logs[0].args.account.should.be.equal(shifter.address)
    })
  })

  describe('#TokenSwap', () => {
    it('should reject if not activated', async () => {
      const holder = accounts[0]
      // Checks if contract has role granted already and grants role if false
      let hasRole = await xft.hasRole(minterRole, tokenSwap.address)
      if (!hasRole) {
        await xft.grantRole(minterRole, tokenSwap.address)
      }
      let balanceBefore = await xftOld.balanceOf(holder)
      await xftOld.approve.sendTransaction(tokenSwap.address, balanceBefore, {from: holder})
      let { reason } = await tokenSwap.upgrade.sendTransaction({from: holder}).should.be.rejected
      reason.should.be.equal("You can't upgrade yet");
    })

    it('should work after activation', async () => {
      const holder = accounts[0]
      // Checks if contract has role granted already and grants role if false
      let hasRole = await xft.hasRole(minterRole, tokenSwap.address)
      if (!hasRole) {
        await xft.grantRole(minterRole, tokenSwap.address)
      }
      await tokenSwap.setActive.sendTransaction({from: holder});
      let balanceBefore = await xftOld.balanceOf(holder)
      let balanceNewBefore = await xft.balanceOf(holder)
      await xftOld.approve.sendTransaction(tokenSwap.address, balanceBefore, {from: holder})
      let { logs } = await tokenSwap.upgrade.sendTransaction({from: holder})
      let balanceOld = await xftOld.balanceOf(holder)
      let balanceNew = await xft.balanceOf(holder)
      let balanceContract = await xft.balanceOf(tokenSwap.address)
      // Tests
      logs[0].event.should.be.equal('XFTUpgraded')
      logs[0].args.user.should.be.equal(holder)
      logs[0].args.amount.should.be.eq.BN(balanceBefore)
      balanceNew.should.be.eq.BN(balanceBefore.add(balanceNewBefore))
      balanceOld.should.be.eq.BN(0)
      balanceContract.should.be.eq.BN(0)
    })
    it('should reject if contract does not have minterRole', async () => {
      const holder = accounts[0]
      await tokenSwap.setActive.sendTransaction({from: holder});
      let hasRole = await xft.hasRole(minterRole, tokenSwap.address)
      if (hasRole) {
        await xft.revokeRole(minterRole, tokenSwap.address)
      }
      let balanceBefore = await xftOld.balanceOf(holder)
      await xftOld.approve.sendTransaction(tokenSwap.address, balanceBefore, {from: holder})
      let error = await tokenSwap.upgrade.sendTransaction({ from: holder }).should.be.rejected
      error.reason.should.be.equal('Caller is not a minter')
    })
  })

  describe('#deposit', () => {
    it('should work', async () => {
      const commitment = toFixedHex(43)
      let role0 = await xft.hasRole(burnerRole, shifter.address)
      role0.should.be.equal(true)
      await xft.approve(shifter.address, tokenDenomination)
      hexNote = await encryptNote(generateDeposit(), storagePassword);

      let { logs } = await shifter.deposit(commitment, hexNote, storageHash, { from: sender, value: "50000000000000000" })

      logs[0].event.should.be.equal('Deposit')
      logs[0].args.commitment.should.be.equal(commitment)
      logs[0].args.leafIndex.should.be.eq.BN(0)
    })

    it('should reject insufficient balance', async () => {
      const commitment = toFixedHex(43)

      await xft.approve(shifter.address, tokenDenomination)
      let user = accounts[5]
      hexNote = await encryptNote(generateDeposit(), storagePassword);

      let error = await shifter.deposit(commitment, hexNote, storageHash, { from: user, value: "50000000000000000" }).should.be.rejected
      error.reason.should.be.equal('Insufficient Balance')
    })

    it('should revert if contract is not assigned burnerRole', async () => {
      const commitment = toFixedHex(43)

      // Checks if contract has role granted already and revokes role if true
      let hasRole = await xft.hasRole(burnerRole, shifter.address)
      if (hasRole) {
        await xft.revokeRole(burnerRole, shifter.address)
      }
      await xft.approve(shifter.address, tokenDenomination)

      hexNote = await encryptNote(generateDeposit(), storagePassword);

      let error = await shifter.deposit(commitment, hexNote, storageHash, { from: sender, value: "50000000000000000" }).should.be.rejected
      error.reason.should.be.equal('Caller is not a burner')
    })
  })

  describe('#withdraw', () => {
    it('should work (and fetch the note onchain)', async () => {
      const deposit = generateDeposit()
      const user = sender;

      const cost = await shifter.getCost(tokenDenomination);
      await xft.mint(user, cost + "0") // Give it 10x the cost

      tree.insert(deposit.commitment)


      const balanceUserBefore = await xft.balanceOf(user)
      await xft.approve(shifter.address, tokenDenomination, { from: user })
      hexNote = await encryptNote(deposit, storagePassword);

      await shifter.deposit(toFixedHex(deposit.commitment), hexNote, storageHash, { from: sender, gasPrice: '0', value: "50000000000000000" })

      const latestDeposit = await storage.getLatestDeposit(shifter.address, user, storageHash)

      hexNote = await xcrypt(latestDeposit, storagePassword);

      // Chainlink Feed only has 8 decimals of precision.
      // Price changes on the fly, can never have accurate price due to atomicity of txs unless no oracles change in between txs

      const { pathElements, pathIndices } = tree.path(0)
      // Circuit input
      const input = stringifyBigInts({
        // public
        root: tree.root(),
        nullifierHash: pedersenHash(deposit.nullifier.leInt2Buff(31)),
        relayer,
        recipient,
        fee,
        refund,

        // private
        nullifier: deposit.nullifier,
        secret: deposit.secret,
        pathElements: pathElements,
        pathIndices: pathIndices,
      })

      const proofData = await websnarkUtils.genWitnessAndProve(groth16, input, circuit, proving_key)
      const { proof } = websnarkUtils.toSolidityInput(proofData)

      const balanceShifterBefore = await xft.balanceOf(shifter.address)
      const balanceRelayerBefore = await token.balanceOf(relayer)
      const balanceReceiverBefore = await token.balanceOf(toFixedHex(recipient, 20))

      const ethBalanceOperatorBefore = await web3.eth.getBalance(operator)
      const ethBalanceReceiverBefore = await web3.eth.getBalance(toFixedHex(recipient, 20))
      const ethBalanceRelayerBefore = await web3.eth.getBalance(relayer)
      let isSpent = await shifter.isSpent(toFixedHex(input.nullifierHash))
      isSpent.should.be.equal(false)
      const args = [
        toFixedHex(input.root),
        toFixedHex(input.nullifierHash),
        toFixedHex(input.recipient, 20),
        toFixedHex(input.relayer, 20),
        toFixedHex(input.fee),
        toFixedHex(input.refund),
      ]
      const { logs } = await shifter.withdraw(proof, ...args, { value: refund, from: relayer, gasPrice: '0' })

      const balanceRelayerAfter = await token.balanceOf(relayer)
      const ethBalanceOperatorAfter = await web3.eth.getBalance(operator)
      const balanceReceiverAfter = await token.balanceOf(toFixedHex(recipient, 20))
      const ethBalanceReceiverAfter = await web3.eth.getBalance(toFixedHex(recipient, 20))
      const ethBalanceRelayerAfter = await web3.eth.getBalance(relayer)
      const feeBN = toBN(fee.toString())
      balanceRelayerAfter.should.be.eq.BN(toBN(balanceRelayerBefore).add(feeBN))
      balanceReceiverAfter.should.be.eq.BN(
        toBN(balanceReceiverBefore).add(toBN(tokenDenomination).sub(feeBN)),
      )

      ethBalanceOperatorAfter.should.be.eq.BN(toBN(ethBalanceOperatorBefore))
      ethBalanceReceiverAfter.should.be.eq.BN(toBN(ethBalanceReceiverBefore).add(toBN(refund)).add(toBN("50000000000000000"))) //Adds the deposited ether ToDo: Fix hardcoded value
      ethBalanceRelayerAfter.should.be.eq.BN(toBN(ethBalanceRelayerBefore).sub(toBN(refund)))

      logs[0].args.nullifierHash.should.be.equal(toFixedHex(input.nullifierHash))
      logs[0].args.relayer.should.be.eq.BN(relayer)
      logs[0].args.fee.should.be.eq.BN(feeBN)
      isSpent = await shifter.isSpent(toFixedHex(input.nullifierHash))
      isSpent.should.be.equal(true)
    })

    it('should reject with wrong refund value', async () => {
      const deposit = generateDeposit()
      const user = accounts[4]
      const cost = await shifter.getCost(tokenDenomination);
      await xft.mint(user, cost + "0") // Give it 10x the cost

      tree.insert(deposit.commitment)
      await xft.approve(shifter.address, tokenDenomination, { from: user })

      await shifter.deposit(toFixedHex(deposit.commitment), toFixedHex(rbigint(62)), storageHash, { from: user, gasPrice: '0', value: "50000000000000000" })

      const { pathElements, pathIndices } = tree.path(0)
      // Circuit input
      const input = stringifyBigInts({
        // public
        root: tree.root(),
        nullifierHash: pedersenHash(deposit.nullifier.leInt2Buff(31)),
        relayer,
        recipient,
        fee,
        refund,

        // private
        nullifier: deposit.nullifier,
        secret: deposit.secret,
        pathElements: pathElements,
        pathIndices: pathIndices,
      })

      const proofData = await websnarkUtils.genWitnessAndProve(groth16, input, circuit, proving_key)
      const { proof } = websnarkUtils.toSolidityInput(proofData)

      const args = [
        toFixedHex(input.root),
        toFixedHex(input.nullifierHash),
        toFixedHex(input.recipient, 20),
        toFixedHex(input.relayer, 20),
        toFixedHex(input.fee),
        toFixedHex(input.refund),
      ]
      let { reason } = await shifter.withdraw(proof, ...args, { value: 1, from: relayer, gasPrice: '0' })
        .should.be.rejected
      reason.should.be.equal('Incorrect refund amount received by the contract')
        ; ({ reason } = await shifter.withdraw(proof, ...args, {
          value: toBN(refund).mul(toBN(2)),
          from: relayer,
          gasPrice: '0',
        }).should.be.rejected)
      reason.should.be.equal('Incorrect refund amount received by the contract')
    })

    it('should revert if contract not assigned minterRole', async () => {
      const deposit = generateDeposit()
      hexNote = await encryptNote(generateDeposit(), storagePassword);
      const user = accounts[4]
      const cost = await shifter.getCost(tokenDenomination);
      await xft.mint(user, cost + "0") // Give it 10x the cost

      // Checks if contract has role granted already and revokes role if true
      let hasRole = await token.hasRole(minterRole, shifter.address)
      if (hasRole) {
        await token.revokeRole(minterRole, shifter.address)
      }

      tree.insert(deposit.commitment)

      const balanceUserBefore = await xft.balanceOf(user)
      await xft.approve(shifter.address, tokenDenomination, { from: user })
      await shifter.deposit(toFixedHex(deposit.commitment), hexNote, storageHash, { from: user, gasPrice: '0', value: "50000000000000000" })

      const balanceUserAfter = await xft.balanceOf(user)

      const { pathElements, pathIndices } = tree.path(0)
      // Circuit input
      const input = stringifyBigInts({
        // public
        root: tree.root(),
        nullifierHash: pedersenHash(deposit.nullifier.leInt2Buff(31)),
        relayer,
        recipient,
        fee,
        refund,

        // private
        nullifier: deposit.nullifier,
        secret: deposit.secret,
        pathElements: pathElements,
        pathIndices: pathIndices,
      })

      const proofData = await websnarkUtils.genWitnessAndProve(groth16, input, circuit, proving_key)
      const { proof } = websnarkUtils.toSolidityInput(proofData)

      const balanceShifterBefore = await xft.balanceOf(shifter.address)
      const balanceRelayerBefore = await token.balanceOf(relayer)
      const balanceReceiverBefore = await token.balanceOf(toFixedHex(recipient, 20))

      const ethBalanceOperatorBefore = await web3.eth.getBalance(operator)
      const ethBalanceReceiverBefore = await web3.eth.getBalance(toFixedHex(recipient, 20))
      const ethBalanceRelayerBefore = await web3.eth.getBalance(relayer)
      let isSpent = await shifter.isSpent(toFixedHex(input.nullifierHash))
      isSpent.should.be.equal(false)
      const args = [
        toFixedHex(input.root),
        toFixedHex(input.nullifierHash),
        toFixedHex(input.recipient, 20),
        toFixedHex(input.relayer, 20),
        toFixedHex(input.fee),
        toFixedHex(input.refund),
      ]
      let { reason } = await shifter.withdraw(proof, ...args, { value: refund, from: relayer, gasPrice: '0' }).should.be.rejected
      reason.should.be.equal('Caller is not a minter')
    })
  })

  describe('#simpleShift', () => {
    it('should work', async () => {
      const user = accounts[4]
      const amount = "10000";
      const chainlinkFeed = await shifter.chainlinkFeed();
      const xftPool = await shifter.xftPool();
      const tokenPool = await shifter.tokenPool();

      await token.mint(user, amount)
      const cost = await oracle.getCostSimpleShift(amount, chainlinkFeed, xftPool, tokenPool, {from: user})
      await shifter.simpleShift(amount, user, { from: user })

      senderTokenBalance = await xft.balanceOf(user)
      senderUSDBalance = await token.balanceOf(user)

      // Find a way to atomically check balances
      senderTokenBalance.should.be.eq.BN(cost)
      senderUSDBalance.should.be.eq.BN(toBN(0))

    })

    it('should revert if sender has insufficient balance', async () => {

      let { reason } = await shifter.simpleShift(tokenDenomination, sender, { from: sender }).should.be.rejected
      reason.should.be.equal('Insufficient balance')

    })

  })

  describe('#Paused', () => {
    it('should work', async () => {
      await shifter.pause({ from: sender })
      let isPaused = await shifter.paused()
      isPaused.should.be.equal(true)

      let { reason } = await shifter.simpleShift(tokenDenomination, sender, { from: sender }).should.be.rejected
      reason.should.be.equal('Pausable: paused')
    })
  })

  afterEach(async () => {
    await revertSnapshot(snapshotId.result)
    // eslint-disable-next-line require-atomic-updates
    snapshotId = await takeSnapshot()
    tree = new MerkleTree(levels)
  })
})