import IUniswapV3PoolArtifact from '@artifacts/@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json';
import { FakeContract, MockContract, MockContractFactory, smock } from '@defi-wonderland/smock';
import { KP3R_V1_ADDRESS, KP3R_WETH_V3_POOL_ADDRESS } from '@e2e/common';
import { BigNumber } from '@ethersproject/bignumber';
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';
import IKeep3rV1Artifact from '@solidity/interfaces/external/IKeep3rV1.sol/IKeep3rV1.json';
import IKeep3rArtifact from '@solidity/interfaces/IKeep3r.sol/IKeep3r.json';
import { IKeep3r, IKeep3rV1, IUniswapV3Pool, Keep3rHelperForTest, Keep3rHelperForTest__factory, ProxyForTest__factory } from '@types';
import { behaviours, wallet } from '@utils';
import { toGwei, toUnit } from '@utils/bn';
import { MathUtils, mathUtilsFactory } from '@utils/math';
import chai, { expect } from 'chai';
import { solidity } from 'ethereum-waffle';
import { ethers } from 'hardhat';

chai.use(solidity);

describe('Keep3rHelper', () => {
  let oraclePool: FakeContract<IUniswapV3Pool>;
  let keep3r: FakeContract<IKeep3r>;
  let keep3rV1: FakeContract<IKeep3rV1>;
  let helperFactory: MockContractFactory<Keep3rHelperForTest__factory>;
  let helper: MockContract<Keep3rHelperForTest>;

  let kp3rV1Address: string;
  let targetBond: BigNumber;
  let governance: SignerWithAddress;
  let randomKeeper: SignerWithAddress;

  let workExtraGas: BigNumber;
  let quoteTwapTime: number;
  let oneTenthTick0: BigNumber;
  let oneTenthTick1: BigNumber;

  let mathUtils: MathUtils;

  before(async () => {
    [, governance, randomKeeper] = await ethers.getSigners();

    helperFactory = await smock.mock<Keep3rHelperForTest__factory>('Keep3rHelperForTest');
    keep3r = await smock.fake(IKeep3rArtifact);
    oraclePool = await smock.fake(IUniswapV3PoolArtifact, { address: KP3R_WETH_V3_POOL_ADDRESS });
    oraclePool.token1.returns(KP3R_V1_ADDRESS);
    helper = await helperFactory.deploy(keep3r.address, governance.address);

    kp3rV1Address = await helper.callStatic.KP3R();
    targetBond = await helper.callStatic.targetBond();
    quoteTwapTime = await helper.callStatic.quoteTwapTime();
    workExtraGas = await helper.callStatic.workExtraGas();

    // Twap calculation: 1.0001 ** (-23027) = 0.100000022 ~= 0.1
    oneTenthTick0 = BigNumber.from(-23027).mul(quoteTwapTime);
    oneTenthTick1 = BigNumber.from(0);

    keep3rV1 = await smock.fake(IKeep3rV1Artifact, { address: kp3rV1Address });
    mathUtils = mathUtilsFactory(0, 0);

    oraclePool.observe.returns([[oneTenthTick0, oneTenthTick1], []]);
  });

  beforeEach(async () => {
    keep3r.bonds.reset();
  });

  describe('quote', () => {
    it('should return keep3r KP3R/WETH quote', async () => {
      const toQuote = toUnit(10);
      const quoteResult = toUnit(1);

      const actualQuote = await helper.callStatic.quote(toQuote);
      expect(actualQuote).to.be.closeTo(quoteResult, toUnit(0.001).toNumber());
    });

    it('should work with 100 ETH', async () => {
      await expect(helper.quote(toUnit(100))).not.to.be.reverted;
    });
  });

  describe('bonds', () => {
    it('should return amount of KP3R bonds', async () => {
      const bondsResult = toUnit(1);
      keep3r.bonds.whenCalledWith(randomKeeper.address, keep3rV1.address).returns(bondsResult);

      expect(await helper.callStatic.bonds(randomKeeper.address)).to.equal(bondsResult);
    });
  });

  describe('getRewardAmountFor', () => {
    const baseFee = toGwei(200);
    const gasUsed = BigNumber.from(30_000_000);
    let expectedQuoteAmount: BigNumber;

    beforeEach(async () => {
      expectedQuoteAmount = gasUsed.mul(baseFee).div(10);
      await helper.setVariable('basefee', baseFee);
    });

    it('should call bonds with the correct arguments', async () => {
      await helper.callStatic.getRewardAmountFor(randomKeeper.address, gasUsed);
      expect(keep3r.bonds).to.be.calledOnceWith(randomKeeper.address, keep3rV1.address);
    });

    it('should return at least 110% of the quote', async () => {
      expect(await helper.callStatic.getRewardAmountFor(randomKeeper.address, gasUsed)).to.closeTo(
        expectedQuoteAmount.mul(11).div(10),
        toUnit(0.0001).toNumber()
      );
    });

    it('should boost the quote depending on the bonded KP3R of the keeper', async () => {
      keep3r.bonds.whenCalledWith(randomKeeper.address).returns(targetBond.sub(toUnit(1)));
      // TODO: remove as any when this is solved: https://github.com/EthWorks/Waffle/issues/561
      expect(await helper.callStatic.getRewardAmountFor(randomKeeper.address, gasUsed)).to.be.within(
        expectedQuoteAmount.mul(11).div(10) as any,
        expectedQuoteAmount.mul(12).div(10) as any
      );
    });

    it('should return at most 120% of the quote', async () => {
      keep3r.bonds.whenCalledWith(randomKeeper.address, keep3rV1.address).returns(targetBond.mul(10));

      expect(await helper.callStatic.getRewardAmountFor(randomKeeper.address, gasUsed)).to.closeTo(
        expectedQuoteAmount.mul(12).div(10),
        toUnit(0.0001).toNumber()
      );
    });
  });

  describe('getRewardAmount', () => {
    const baseFee = toGwei(200);
    const gasUsed = BigNumber.from(30_000_000);
    let expectedQuoteAmount: BigNumber;

    beforeEach(async () => {
      expectedQuoteAmount = gasUsed.mul(baseFee).div(10);
      await helper.setVariable('basefee', baseFee);
    });

    it('should call bonds with the correct arguments', async () => {
      const proxyFactory = (await ethers.getContractFactory('ProxyForTest')) as ProxyForTest__factory;
      const proxy = await proxyFactory.deploy();

      // call getRewardAmount through proxy
      await proxy.connect(randomKeeper).call(helper.address, helper.interface.encodeFunctionData('getRewardAmount', [gasUsed]));

      // should use tx.origin and not msg.sender
      expect(keep3r.bonds).to.be.calledOnceWith(randomKeeper.address, keep3rV1.address);
    });

    it('should return at least 110% of the quote', async () => {
      expect(await helper.callStatic.getRewardAmount(gasUsed)).to.closeTo(expectedQuoteAmount.mul(11).div(10), toUnit(0.0001).toNumber());
    });

    it('should boost the quote depending on the bonded KP3R of the keeper', async () => {
      keep3r.bonds.returns(targetBond.sub(toUnit(1)));
      // TODO: remove as any when this is solved: https://github.com/EthWorks/Waffle/issues/561
      expect(await helper.callStatic.getRewardAmount(gasUsed)).to.be.within(
        expectedQuoteAmount.mul(11).div(10) as any,
        expectedQuoteAmount.mul(12).div(10) as any
      );
    });

    it('should return at most 120% of the quote', async () => {
      keep3r.bonds.returns(targetBond.mul(10));

      expect(await helper.callStatic.getRewardAmount(gasUsed)).to.closeTo(expectedQuoteAmount.mul(12).div(10), toUnit(0.0001).toNumber());
    });
  });

  describe('getRewardBoostFor', () => {
    const baseFee = toGwei(200);

    beforeEach(async () => {
      await helper.setVariable('basefee', baseFee);
    });

    it('should return at least 110% boost on gasPrice', async () => {
      expect(await helper.getRewardBoostFor(0)).to.be.eq(baseFee.mul(11000));
    });

    it('should boost gasPrice depending on the bonded KP3R of the keeper', async () => {
      const min = 11000;
      const max = 12000;
      expect((await helper.getRewardBoostFor(targetBond.div(2))).div(baseFee)).to.be.eq((min + max) / 2);
    });

    it('should return at most a 120% boost on gasPrice', async () => {
      expect(await helper.getRewardBoostFor(targetBond.mul(10))).to.be.be.eq(baseFee.mul(12000));
    });
  });

  describe('getPoolTokens', () => {
    it('should return the underlying tokens of the requested pool', async () => {
      const token0 = wallet.generateRandomAddress();
      const token1 = wallet.generateRandomAddress();

      oraclePool.token0.returns(token0);
      oraclePool.token1.returns(token1);

      expect(await helper.getPoolTokens(oraclePool.address)).to.deep.eq([token0, token1]);
    });
  });

  describe('isKP3RToken0', () => {
    it('should revert if none of the underlying tokens is KP3R', async () => {
      oraclePool.token0.returns(wallet.generateRandomAddress());
      oraclePool.token1.returns(wallet.generateRandomAddress());

      await expect(helper.isKP3RToken0(oraclePool.address)).to.be.revertedWith('LiquidityPairInvalid()');
    });

    it('should return true if KP3R is token0 of the pool', async () => {
      oraclePool.token0.returns(await helper.KP3R());
      oraclePool.token1.returns(wallet.generateRandomAddress());

      expect(await helper.isKP3RToken0(oraclePool.address)).to.be.true;
    });

    it('should return false if KP3R is token0 of the pool', async () => {
      oraclePool.token0.returns(wallet.generateRandomAddress());
      oraclePool.token1.returns(await helper.KP3R());

      expect(await helper.isKP3RToken0(oraclePool.address)).to.be.false;
    });
  });

  describe('observe', () => {
    const secondsAgo = [10];
    const tick1 = BigNumber.from(1);

    beforeEach(() => {
      oraclePool.observe.reset();
      oraclePool.observe.returns([[tick1], []]);
    });

    it('should return false success when observe fails', async () => {
      oraclePool.observe.reverts();
      const result = await helper.callStatic.observe(oraclePool.address, secondsAgo);
      expect(result).to.deep.equal([BigNumber.from(0), BigNumber.from(0), false]);
    });

    it('should call pool observe with given seconds ago', async () => {
      await helper.callStatic.observe(oraclePool.address, secondsAgo);
      expect(oraclePool.observe).to.be.calledOnceWith(secondsAgo);
    });

    it('should return response first item', async () => {
      const result = await helper.callStatic.observe(oraclePool.address, secondsAgo);
      expect(result).to.deep.equal([tick1, BigNumber.from(0), true]);
    });

    it('should return response first and second item if given', async () => {
      const tick2 = BigNumber.from(2);
      oraclePool.observe.returns([[tick1, tick2, 123], []]);
      const result = await helper.callStatic.observe(oraclePool.address, secondsAgo);
      expect(result).to.deep.equal([tick1, tick2, true]);
    });
  });

  describe('getKP3RsAtTick', () => {
    const precision = 1_000_000;
    const liquidityAmount = toUnit(1);
    const tickTimeDifference = 1;
    const tick2 = 0;

    it('should calculate the underlying tokens from a UniswapV3Pool liquidity', async () => {
      /* Calculation
      // liquidity = sqrt( x * y )
      // sqrtPrice = sqrt( y / x )
      // sqrtPrice = 1.0001 ^ tick/2 = 1.0001 ^ (t1-t2)/2*tickTimeDifference
      // x = liquidity / sqrtPrice
      */

      // Twap calculation: 1.0001 ** (-23027) = 0.100000022 ~= 0.1
      const tick1 = 23027;

      const sqrtPrice = 1.0001 ** (((tick1 - tick2) / 2) * tickTimeDifference);
      const expectedKP3Rs = liquidityAmount.mul(precision).div(Math.floor(sqrtPrice * precision));

      expect(await helper.getKP3RsAtTick(liquidityAmount, tick1 - tick2, tickTimeDifference)).to.be.closeTo(
        expectedKP3Rs,
        toUnit(0.0001).toNumber()
      );
    });

    it('should return a decreased amount if tick is increased', async () => {
      const tick1 = 1;

      expect(await helper.getKP3RsAtTick(liquidityAmount, tick1 - tick2, tickTimeDifference)).to.be.closeTo(
        mathUtils.decrease1Tick(liquidityAmount),
        toUnit(0.0001).toNumber()
      );
    });

    it('should return a increased amount if tick is decreased', async () => {
      const tick1 = -1;

      expect(await helper.getKP3RsAtTick(liquidityAmount, tick1 - tick2, tickTimeDifference)).to.be.closeTo(
        mathUtils.increase1Tick(liquidityAmount),
        toUnit(0.0001).toNumber()
      );
    });
  });

  describe('getQuoteAtTick', () => {
    const precision = 1_000_000;
    const baseAmount = toUnit(3);
    const tickTimeDifference = 1;
    // Twap calculation: 1.0001 ** (-23027) = 0.100000022 ~= 0.1
    const tick1 = -23027;
    const tick2 = 0;

    it('should calculate a token conversion from a tick', async () => {
      /* Calculation
      // sqrtPrice = sqrt( y / x )
      // price = 1.0001 ^ tick = 1.0001 ^ (t2-t1)/tickTimeDifference
      // x = price * y
      */

      const price = 1.0001 ** ((tick1 - tick2) / tickTimeDifference);
      const expectedQuote = baseAmount.mul(precision).div(Math.floor(price * precision));

      expect(await helper.getQuoteAtTick(baseAmount, tick1 - tick2, tickTimeDifference)).to.be.closeTo(
        expectedQuote,
        toUnit(0.00001).toNumber()
      );
    });
  });

  describe('setWorkExtraGas', () => {
    const newValue = 123;
    behaviours.onlyGovernance(() => helper, 'setWorkExtraGas', governance, [newValue]);

    it('should assign specified value to variable', async () => {
      expect(await helper.callStatic.workExtraGas()).not.to.equal(newValue);
      await helper.connect(governance).setWorkExtraGas(newValue);
      expect(await helper.callStatic.workExtraGas()).to.equal(newValue);
    });

    it('should emit event', async () => {
      await expect(helper.connect(governance).setWorkExtraGas(newValue)).to.emit(helper, 'WorkExtraGasChange').withArgs(newValue);
    });
  });

  describe('getPaymentParams', () => {
    const bonds = toUnit(150);

    let boost: BigNumber;
    let oneEthQuote: BigNumber;
    let extraGas: BigNumber;

    before(async () => {
      [boost, oneEthQuote, extraGas] = await helper.callStatic.getPaymentParams(bonds);
    });

    it('should return 1 eth quoted with getQuoteAtTick', async () => {
      const expectedQuote = await helper.callStatic.quote(toUnit(1));
      expect(expectedQuote).to.equal(oneEthQuote);
    });

    it('should return boost from getRewardBoostFor', async () => {
      const expectedBoost = await helper.callStatic.getRewardBoostFor(bonds);
      expect(expectedBoost).to.equal(boost);
    });

    it('should return workExtraGas', async () => {
      const expectedExtraGas = await helper.callStatic.workExtraGas();
      expect(expectedExtraGas).to.equal(extraGas);
    });
  });
});
