const { accounts, contract, web3, config } = require('@openzeppelin/test-environment');

const { balance, ether, expectRevert, send, expectEvent } = require('@openzeppelin/test-helpers');
const { expect } = require('chai');

const AddressImpl = contract.fromArtifact('AddressImpl');
const EtherReceiver = contract.fromArtifact('EtherReceiverMock');
const CallReceiverMock = contract.fromArtifact('CallReceiverMock');

const coverage = config.coverage;

describe('Address', function () {
  const [ recipient, other ] = accounts;

  beforeEach(async function () {
    this.mock = await AddressImpl.new();
  });

  describe('isContract', function () {
    it('returns false for account address', async function () {
      expect(await this.mock.isContract(other)).to.equal(false);
    });

    it('returns true for contract address', async function () {
      const contract = await AddressImpl.new();
      expect(await this.mock.isContract(contract.address)).to.equal(true);
    });
  });

  describe('sendValue', function () {
    beforeEach(async function () {
      this.recipientTracker = await balance.tracker(recipient);
    });

    context('when sender contract has no funds', function () {
      it('sends 0 wei', async function () {
        await this.mock.sendValue(other, 0);

        expect(await this.recipientTracker.delta()).to.be.bignumber.equal('0');
      });

      it('reverts when sending non-zero amounts', async function () {
        await expectRevert(this.mock.sendValue(other, 1), 'Address: insufficient balance');
      });
    });

    context('when sender contract has funds', function () {
      const funds = ether('1');
      beforeEach(async function () {
        await send.ether(other, this.mock.address, funds);
      });

      it('sends 0 wei', async function () {
        await this.mock.sendValue(recipient, 0);
        expect(await this.recipientTracker.delta()).to.be.bignumber.equal('0');
      });

      it('sends non-zero amounts', async function () {
        await this.mock.sendValue(recipient, funds.subn(1));
        expect(await this.recipientTracker.delta()).to.be.bignumber.equal(funds.subn(1));
      });

      it('sends the whole balance', async function () {
        await this.mock.sendValue(recipient, funds);
        expect(await this.recipientTracker.delta()).to.be.bignumber.equal(funds);
        expect(await balance.current(this.mock.address)).to.be.bignumber.equal('0');
      });

      it('reverts when sending more than the balance', async function () {
        await expectRevert(this.mock.sendValue(recipient, funds.addn(1)), 'Address: insufficient balance');
      });

      context('with contract recipient', function () {
        beforeEach(async function () {
          this.contractRecipient = await EtherReceiver.new();
        });

        it('sends funds', async function () {
          const tracker = await balance.tracker(this.contractRecipient.address);

          await this.contractRecipient.setAcceptEther(true);
          await this.mock.sendValue(this.contractRecipient.address, funds);
          expect(await tracker.delta()).to.be.bignumber.equal(funds);
        });

        it('reverts on recipient revert', async function () {
          await this.contractRecipient.setAcceptEther(false);
          await expectRevert(
            this.mock.sendValue(this.contractRecipient.address, funds),
            'Address: unable to send value, recipient may have reverted',
          );
        });
      });
    });
  });

  describe('functionCall', function () {
    beforeEach(async function () {
      this.contractRecipient = await CallReceiverMock.new();
    });

    context('with valid contract receiver', function () {
      it('calls the requested function', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunction',
          type: 'function',
          inputs: [],
        }, []);

        const receipt = await this.mock.functionCall(this.contractRecipient.address, abiEncodedCall);

        expectEvent(receipt, 'CallReturnValue', { data: '0x1234' });
        await expectEvent.inTransaction(receipt.tx, CallReceiverMock, 'MockFunctionCalled');
      });

      it('reverts when the called function reverts with no reason', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunctionRevertsNoReason',
          type: 'function',
          inputs: [],
        }, []);

        await expectRevert(
          this.mock.functionCall(this.contractRecipient.address, abiEncodedCall),
          'Address: low-level call failed',
        );
      });

      it('reverts when the called function reverts, bubbling up the revert reason', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunctionRevertsReason',
          type: 'function',
          inputs: [],
        }, []);

        await expectRevert(
          this.mock.functionCall(this.contractRecipient.address, abiEncodedCall),
          'CallReceiverMock: reverting',
        );
      });

      // Skipped in a coverage mode due to coverage mode setting a block gas limit to 0xffffffffff
      // which cause a mockFunctionOutOfGas function to crash Ganache and the
      // subsequent tests before running out of gas.
      it('reverts when the called function runs out of gas', async function () {
        this.timeout(10000);
        if (coverage) { return this.skip(); }
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunctionOutOfGas',
          type: 'function',
          inputs: [],
        }, []);

        await expectRevert(
          this.mock.functionCall(this.contractRecipient.address, abiEncodedCall),
          'Address: low-level call failed',
        );
      });

      it('reverts when the called function throws', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunctionThrows',
          type: 'function',
          inputs: [],
        }, []);

        await expectRevert(
          this.mock.functionCall(this.contractRecipient.address, abiEncodedCall),
          'Address: low-level call failed',
        );
      });

      it('reverts when function does not exist', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunctionDoesNotExist',
          type: 'function',
          inputs: [],
        }, []);

        await expectRevert(
          this.mock.functionCall(this.contractRecipient.address, abiEncodedCall),
          'Address: low-level call failed',
        );
      });
    });

    context('with non-contract receiver', function () {
      it('reverts when address is not a contract', async function () {
        const [ recipient ] = accounts;
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunction',
          type: 'function',
          inputs: [],
        }, []);
        await expectRevert(this.mock.functionCall(recipient, abiEncodedCall), 'Address: call to non-contract');
      });
    });
  });

  describe('functionCallWithValue', function () {
    beforeEach(async function () {
      this.contractRecipient = await CallReceiverMock.new();
    });

    context('with zero value', function () {
      it('calls the requested function', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunction',
          type: 'function',
          inputs: [],
        }, []);

        const receipt = await this.mock.functionCallWithValue(this.contractRecipient.address, abiEncodedCall, 0);

        expectEvent(receipt, 'CallReturnValue', { data: '0x1234' });
        await expectEvent.inTransaction(receipt.tx, CallReceiverMock, 'MockFunctionCalled');
      });
    });

    context('with non-zero value', function () {
      const amount = ether('1.2');

      it('reverts if insufficient sender balance', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunction',
          type: 'function',
          inputs: [],
        }, []);

        await expectRevert(
          this.mock.functionCallWithValue(this.contractRecipient.address, abiEncodedCall, amount),
          'Address: insufficient balance for call',
        );
      });

      it('calls the requested function with existing value', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunction',
          type: 'function',
          inputs: [],
        }, []);

        const tracker = await balance.tracker(this.contractRecipient.address);

        await send.ether(other, this.mock.address, amount);
        const receipt = await this.mock.functionCallWithValue(this.contractRecipient.address, abiEncodedCall, amount);

        expect(await tracker.delta()).to.be.bignumber.equal(amount);

        expectEvent(receipt, 'CallReturnValue', { data: '0x1234' });
        await expectEvent.inTransaction(receipt.tx, CallReceiverMock, 'MockFunctionCalled');
      });

      it('calls the requested function with transaction funds', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunction',
          type: 'function',
          inputs: [],
        }, []);

        const tracker = await balance.tracker(this.contractRecipient.address);

        expect(await balance.current(this.mock.address)).to.be.bignumber.equal('0');
        const receipt = await this.mock.functionCallWithValue(
          this.contractRecipient.address, abiEncodedCall, amount, { from: other, value: amount },
        );

        expect(await tracker.delta()).to.be.bignumber.equal(amount);

        expectEvent(receipt, 'CallReturnValue', { data: '0x1234' });
        await expectEvent.inTransaction(receipt.tx, CallReceiverMock, 'MockFunctionCalled');
      });

      it('reverts when calling non-payable functions', async function () {
        const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
          name: 'mockFunctionNonPayable',
          type: 'function',
          inputs: [],
        }, []);

        await send.ether(other, this.mock.address, amount);
        await expectRevert(
          this.mock.functionCallWithValue(this.contractRecipient.address, abiEncodedCall, amount),
          'Address: low-level call with value failed',
        );
      });
    });
  });

  describe('functionStaticCall', function () {
    beforeEach(async function () {
      this.contractRecipient = await CallReceiverMock.new();
    });

    it('calls the requested function', async function () {
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockStaticFunction',
        type: 'function',
        inputs: [],
      }, []);

      const receipt = await this.mock.functionStaticCall(this.contractRecipient.address, abiEncodedCall);

      expectEvent(receipt, 'CallReturnValue', { data: '0x1234' });
    });

    it('reverts on a non-static function', async function () {
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockFunction',
        type: 'function',
        inputs: [],
      }, []);

      await expectRevert(
        this.mock.functionStaticCall(this.contractRecipient.address, abiEncodedCall),
        'Address: low-level static call failed',
      );
    });

    it('bubbles up revert reason', async function () {
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockFunctionRevertsReason',
        type: 'function',
        inputs: [],
      }, []);

      await expectRevert(
        this.mock.functionStaticCall(this.contractRecipient.address, abiEncodedCall),
        'CallReceiverMock: reverting',
      );
    });

    it('reverts when address is not a contract', async function () {
      const [ recipient ] = accounts;
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockFunction',
        type: 'function',
        inputs: [],
      }, []);
      await expectRevert(
        this.mock.functionStaticCall(recipient, abiEncodedCall),
        'Address: static call to non-contract',
      );
    });
  });

  describe('functionDelegateCall', function () {
    beforeEach(async function () {
      this.contractRecipient = await CallReceiverMock.new();
    });

    it('delegate calls the requested function', async function () {
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockFunctionWritesStorage',
        type: 'function',
        inputs: [],
      }, []);

      const receipt = await this.mock.functionDelegateCall(this.contractRecipient.address, abiEncodedCall);

      expectEvent(receipt, 'CallReturnValue', { data: '0x1234' });

      expect(await this.mock.sharedAnswer()).to.equal('42');
    });

    it('bubbles up revert reason', async function () {
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockFunctionRevertsReason',
        type: 'function',
        inputs: [],
      }, []);

      await expectRevert(
        this.mock.functionDelegateCall(this.contractRecipient.address, abiEncodedCall),
        'CallReceiverMock: reverting',
      );
    });

    it('reverts when address is not a contract', async function () {
      const [ recipient ] = accounts;
      const abiEncodedCall = web3.eth.abi.encodeFunctionCall({
        name: 'mockFunction',
        type: 'function',
        inputs: [],
      }, []);
      await expectRevert(
        this.mock.functionDelegateCall(recipient, abiEncodedCall),
        'Address: delegate call to non-contract',
      );
    });
  });
});
