const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');

const { getDomain, Delegation } = require('../../../helpers/eip712');
const { batchInBlock } = require('../../../helpers/txpool');
const time = require('../../../helpers/time');

const { shouldBehaveLikeVotes } = require('../../../governance/utils/Votes.behavior');

const TOKENS = [
  { Token: '$ERC20Votes', mode: 'blocknumber' },
  { Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
];

const name = 'My Token';
const symbol = 'MTKN';
const version = '1';
const supply = ethers.parseEther('10000000');

describe('ERC20Votes', function () {
  for (const { Token, mode } of TOKENS) {
    const fixture = async () => {
      // accounts is required by shouldBehaveLikeVotes
      const accounts = await ethers.getSigners();
      const [holder, recipient, delegatee, other1, other2] = accounts;

      const token = await ethers.deployContract(Token, [name, symbol, name, version]);
      const domain = await getDomain(token);

      return { accounts, holder, recipient, delegatee, other1, other2, token, domain };
    };

    describe(`vote with ${mode}`, function () {
      beforeEach(async function () {
        Object.assign(this, await loadFixture(fixture));
        this.votes = this.token;
      });

      // includes ERC6372 behavior check
      shouldBehaveLikeVotes([1, 17, 42], { mode, fungible: true });

      it('initial nonce is 0', async function () {
        expect(await this.token.nonces(this.holder)).to.equal(0n);
      });

      it('minting restriction', async function () {
        const value = 2n ** 208n;
        await expect(this.token.$_mint(this.holder, value))
          .to.be.revertedWithCustomError(this.token, 'ERC20ExceededSafeSupply')
          .withArgs(value, value - 1n);
      });

      it('recent checkpoints', async function () {
        await this.token.connect(this.holder).delegate(this.holder);
        for (let i = 0; i < 6; i++) {
          await this.token.$_mint(this.holder, 1n);
        }
        const timepoint = await time.clock[mode]();
        expect(await this.token.numCheckpoints(this.holder)).to.equal(6n);
        // recent
        expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(5n);
        // non-recent
        expect(await this.token.getPastVotes(this.holder, timepoint - 6n)).to.equal(0n);
      });

      describe('set delegation', function () {
        describe('call', function () {
          it('delegation with balance', async function () {
            await this.token.$_mint(this.holder, supply);
            expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress);

            const tx = await this.token.connect(this.holder).delegate(this.holder);
            const timepoint = await time.clockFromReceipt[mode](tx);

            await expect(tx)
              .to.emit(this.token, 'DelegateChanged')
              .withArgs(this.holder, ethers.ZeroAddress, this.holder)
              .to.emit(this.token, 'DelegateVotesChanged')
              .withArgs(this.holder, 0n, supply);

            expect(await this.token.delegates(this.holder)).to.equal(this.holder);
            expect(await this.token.getVotes(this.holder)).to.equal(supply);
            expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n);
            await mine();
            expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply);
          });

          it('delegation without balance', async function () {
            expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress);

            await expect(this.token.connect(this.holder).delegate(this.holder))
              .to.emit(this.token, 'DelegateChanged')
              .withArgs(this.holder, ethers.ZeroAddress, this.holder)
              .to.not.emit(this.token, 'DelegateVotesChanged');

            expect(await this.token.delegates(this.holder)).to.equal(this.holder);
          });
        });

        describe('with signature', function () {
          const nonce = 0n;

          beforeEach(async function () {
            await this.token.$_mint(this.holder, supply);
          });

          it('accept signed delegation', async function () {
            const { r, s, v } = await this.holder
              .signTypedData(
                this.domain,
                { Delegation },
                {
                  delegatee: this.holder.address,
                  nonce,
                  expiry: ethers.MaxUint256,
                },
              )
              .then(ethers.Signature.from);

            expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress);

            const tx = await this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s);
            const timepoint = await time.clockFromReceipt[mode](tx);

            await expect(tx)
              .to.emit(this.token, 'DelegateChanged')
              .withArgs(this.holder, ethers.ZeroAddress, this.holder)
              .to.emit(this.token, 'DelegateVotesChanged')
              .withArgs(this.holder, 0n, supply);

            expect(await this.token.delegates(this.holder)).to.equal(this.holder);

            expect(await this.token.getVotes(this.holder)).to.equal(supply);
            expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n);
            await mine();
            expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply);
          });

          it('rejects reused signature', async function () {
            const { r, s, v } = await this.holder
              .signTypedData(
                this.domain,
                { Delegation },
                {
                  delegatee: this.holder.address,
                  nonce,
                  expiry: ethers.MaxUint256,
                },
              )
              .then(ethers.Signature.from);

            await this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s);

            await expect(this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s))
              .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce')
              .withArgs(this.holder, nonce + 1n);
          });

          it('rejects bad delegatee', async function () {
            const { r, s, v } = await this.holder
              .signTypedData(
                this.domain,
                { Delegation },
                {
                  delegatee: this.holder.address,
                  nonce,
                  expiry: ethers.MaxUint256,
                },
              )
              .then(ethers.Signature.from);

            const tx = await this.token.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s);

            const { args } = await tx
              .wait()
              .then(receipt => receipt.logs.find(event => event.fragment.name == 'DelegateChanged'));
            expect(args[0]).to.not.equal(this.holder);
            expect(args[1]).to.equal(ethers.ZeroAddress);
            expect(args[2]).to.equal(this.delegatee);
          });

          it('rejects bad nonce', async function () {
            const { r, s, v, serialized } = await this.holder
              .signTypedData(
                this.domain,
                { Delegation },
                {
                  delegatee: this.holder.address,
                  nonce,
                  expiry: ethers.MaxUint256,
                },
              )
              .then(ethers.Signature.from);

            const recovered = ethers.verifyTypedData(
              this.domain,
              { Delegation },
              {
                delegatee: this.holder.address,
                nonce: nonce + 1n,
                expiry: ethers.MaxUint256,
              },
              serialized,
            );

            await expect(this.token.delegateBySig(this.holder, nonce + 1n, ethers.MaxUint256, v, r, s))
              .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce')
              .withArgs(recovered, nonce);
          });

          it('rejects expired permit', async function () {
            const expiry = (await time.clock.timestamp()) - time.duration.weeks(1);

            const { r, s, v } = await this.holder
              .signTypedData(
                this.domain,
                { Delegation },
                {
                  delegatee: this.holder.address,
                  nonce,
                  expiry,
                },
              )
              .then(ethers.Signature.from);

            await expect(this.token.delegateBySig(this.holder, nonce, expiry, v, r, s))
              .to.be.revertedWithCustomError(this.token, 'VotesExpiredSignature')
              .withArgs(expiry);
          });
        });
      });

      describe('change delegation', function () {
        beforeEach(async function () {
          await this.token.$_mint(this.holder, supply);
          await this.token.connect(this.holder).delegate(this.holder);
        });

        it('call', async function () {
          expect(await this.token.delegates(this.holder)).to.equal(this.holder);

          const tx = await this.token.connect(this.holder).delegate(this.delegatee);
          const timepoint = await time.clockFromReceipt[mode](tx);

          await expect(tx)
            .to.emit(this.token, 'DelegateChanged')
            .withArgs(this.holder, this.holder, this.delegatee)
            .to.emit(this.token, 'DelegateVotesChanged')
            .withArgs(this.holder, supply, 0n)
            .to.emit(this.token, 'DelegateVotesChanged')
            .withArgs(this.delegatee, 0n, supply);

          expect(await this.token.delegates(this.holder)).to.equal(this.delegatee);

          expect(await this.token.getVotes(this.holder)).to.equal(0n);
          expect(await this.token.getVotes(this.delegatee)).to.equal(supply);
          expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(supply);
          expect(await this.token.getPastVotes(this.delegatee, timepoint - 1n)).to.equal(0n);
          await mine();
          expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(0n);
          expect(await this.token.getPastVotes(this.delegatee, timepoint)).to.equal(supply);
        });
      });

      describe('transfers', function () {
        beforeEach(async function () {
          await this.token.$_mint(this.holder, supply);
        });

        it('no delegation', async function () {
          await expect(this.token.connect(this.holder).transfer(this.recipient, 1n))
            .to.emit(this.token, 'Transfer')
            .withArgs(this.holder, this.recipient, 1n)
            .to.not.emit(this.token, 'DelegateVotesChanged');

          this.holderVotes = 0n;
          this.recipientVotes = 0n;
        });

        it('sender delegation', async function () {
          await this.token.connect(this.holder).delegate(this.holder);

          const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n);
          await expect(tx)
            .to.emit(this.token, 'Transfer')
            .withArgs(this.holder, this.recipient, 1n)
            .to.emit(this.token, 'DelegateVotesChanged')
            .withArgs(this.holder, supply, supply - 1n);

          const { logs } = await tx.wait();
          const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
          for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
            expect(event.index).to.lt(index);
          }

          this.holderVotes = supply - 1n;
          this.recipientVotes = 0n;
        });

        it('receiver delegation', async function () {
          await this.token.connect(this.recipient).delegate(this.recipient);

          const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n);
          await expect(tx)
            .to.emit(this.token, 'Transfer')
            .withArgs(this.holder, this.recipient, 1n)
            .to.emit(this.token, 'DelegateVotesChanged')
            .withArgs(this.recipient, 0n, 1n);

          const { logs } = await tx.wait();
          const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
          for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
            expect(event.index).to.lt(index);
          }

          this.holderVotes = 0n;
          this.recipientVotes = 1n;
        });

        it('full delegation', async function () {
          await this.token.connect(this.holder).delegate(this.holder);
          await this.token.connect(this.recipient).delegate(this.recipient);

          const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n);
          await expect(tx)
            .to.emit(this.token, 'Transfer')
            .withArgs(this.holder, this.recipient, 1n)
            .to.emit(this.token, 'DelegateVotesChanged')
            .withArgs(this.holder, supply, supply - 1n)
            .to.emit(this.token, 'DelegateVotesChanged')
            .withArgs(this.recipient, 0n, 1n);

          const { logs } = await tx.wait();
          const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
          for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
            expect(event.index).to.lt(index);
          }

          this.holderVotes = supply - 1n;
          this.recipientVotes = 1n;
        });

        afterEach(async function () {
          expect(await this.token.getVotes(this.holder)).to.equal(this.holderVotes);
          expect(await this.token.getVotes(this.recipient)).to.equal(this.recipientVotes);

          // need to advance 2 blocks to see the effect of a transfer on "getPastVotes"
          const timepoint = await time.clock[mode]();
          await mine();
          expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(this.holderVotes);
          expect(await this.token.getPastVotes(this.recipient, timepoint)).to.equal(this.recipientVotes);
        });
      });

      // The following tests are a adaptation of https://github.com/compound-finance/compound-protocol/blob/master/tests/Governance/CompTest.js.
      describe('Compound test suite', function () {
        beforeEach(async function () {
          await this.token.$_mint(this.holder, supply);
        });

        describe('balanceOf', function () {
          it('grants to initial account', async function () {
            expect(await this.token.balanceOf(this.holder)).to.equal(supply);
          });
        });

        describe('numCheckpoints', function () {
          it('returns the number of checkpoints for a delegate', async function () {
            await this.token.connect(this.holder).transfer(this.recipient, 100n); //give an account a few tokens for readability
            expect(await this.token.numCheckpoints(this.other1)).to.equal(0n);

            const t1 = await this.token.connect(this.recipient).delegate(this.other1);
            t1.timepoint = await time.clockFromReceipt[mode](t1);
            expect(await this.token.numCheckpoints(this.other1)).to.equal(1n);

            const t2 = await this.token.connect(this.recipient).transfer(this.other2, 10);
            t2.timepoint = await time.clockFromReceipt[mode](t2);
            expect(await this.token.numCheckpoints(this.other1)).to.equal(2n);

            const t3 = await this.token.connect(this.recipient).transfer(this.other2, 10);
            t3.timepoint = await time.clockFromReceipt[mode](t3);
            expect(await this.token.numCheckpoints(this.other1)).to.equal(3n);

            const t4 = await this.token.connect(this.holder).transfer(this.recipient, 20);
            t4.timepoint = await time.clockFromReceipt[mode](t4);
            expect(await this.token.numCheckpoints(this.other1)).to.equal(4n);

            expect(await this.token.checkpoints(this.other1, 0n)).to.deep.equal([t1.timepoint, 100n]);
            expect(await this.token.checkpoints(this.other1, 1n)).to.deep.equal([t2.timepoint, 90n]);
            expect(await this.token.checkpoints(this.other1, 2n)).to.deep.equal([t3.timepoint, 80n]);
            expect(await this.token.checkpoints(this.other1, 3n)).to.deep.equal([t4.timepoint, 100n]);
            await mine();
            expect(await this.token.getPastVotes(this.other1, t1.timepoint)).to.equal(100n);
            expect(await this.token.getPastVotes(this.other1, t2.timepoint)).to.equal(90n);
            expect(await this.token.getPastVotes(this.other1, t3.timepoint)).to.equal(80n);
            expect(await this.token.getPastVotes(this.other1, t4.timepoint)).to.equal(100n);
          });

          it('does not add more than one checkpoint in a block', async function () {
            await this.token.connect(this.holder).transfer(this.recipient, 100n);
            expect(await this.token.numCheckpoints(this.other1)).to.equal(0n);

            const [t1, t2, t3] = await batchInBlock([
              () => this.token.connect(this.recipient).delegate(this.other1, { gasLimit: 200000 }),
              () => this.token.connect(this.recipient).transfer(this.other2, 10n, { gasLimit: 200000 }),
              () => this.token.connect(this.recipient).transfer(this.other2, 10n, { gasLimit: 200000 }),
            ]);
            t1.timepoint = await time.clockFromReceipt[mode](t1);
            t2.timepoint = await time.clockFromReceipt[mode](t2);
            t3.timepoint = await time.clockFromReceipt[mode](t3);

            expect(await this.token.numCheckpoints(this.other1)).to.equal(1);
            expect(await this.token.checkpoints(this.other1, 0n)).to.be.deep.equal([t1.timepoint, 80n]);

            const t4 = await this.token.connect(this.holder).transfer(this.recipient, 20n);
            t4.timepoint = await time.clockFromReceipt[mode](t4);

            expect(await this.token.numCheckpoints(this.other1)).to.equal(2n);
            expect(await this.token.checkpoints(this.other1, 1n)).to.be.deep.equal([t4.timepoint, 100n]);
          });
        });

        describe('getPastVotes', function () {
          it('reverts if block number >= current block', async function () {
            const clock = await this.token.clock();
            await expect(this.token.getPastVotes(this.other1, 50_000_000_000n))
              .to.be.revertedWithCustomError(this.token, 'ERC5805FutureLookup')
              .withArgs(50_000_000_000n, clock);
          });

          it('returns 0 if there are no checkpoints', async function () {
            expect(await this.token.getPastVotes(this.other1, 0n)).to.equal(0n);
          });

          it('returns the latest block if >= last checkpoint block', async function () {
            const tx = await this.token.connect(this.holder).delegate(this.other1);
            const timepoint = await time.clockFromReceipt[mode](tx);
            await mine(2);

            expect(await this.token.getPastVotes(this.other1, timepoint)).to.equal(supply);
            expect(await this.token.getPastVotes(this.other1, timepoint + 1n)).to.equal(supply);
          });

          it('returns zero if < first checkpoint block', async function () {
            await mine();
            const tx = await this.token.connect(this.holder).delegate(this.other1);
            const timepoint = await time.clockFromReceipt[mode](tx);
            await mine(2);

            expect(await this.token.getPastVotes(this.other1, timepoint - 1n)).to.equal(0n);
            expect(await this.token.getPastVotes(this.other1, timepoint + 1n)).to.equal(supply);
          });

          it('generally returns the voting balance at the appropriate checkpoint', async function () {
            const t1 = await this.token.connect(this.holder).delegate(this.other1);
            await mine(2);
            const t2 = await this.token.connect(this.holder).transfer(this.other2, 10);
            await mine(2);
            const t3 = await this.token.connect(this.holder).transfer(this.other2, 10);
            await mine(2);
            const t4 = await this.token.connect(this.other2).transfer(this.holder, 20);
            await mine(2);

            t1.timepoint = await time.clockFromReceipt[mode](t1);
            t2.timepoint = await time.clockFromReceipt[mode](t2);
            t3.timepoint = await time.clockFromReceipt[mode](t3);
            t4.timepoint = await time.clockFromReceipt[mode](t4);

            expect(await this.token.getPastVotes(this.other1, t1.timepoint - 1n)).to.equal(0n);
            expect(await this.token.getPastVotes(this.other1, t1.timepoint)).to.equal(supply);
            expect(await this.token.getPastVotes(this.other1, t1.timepoint + 1n)).to.equal(supply);
            expect(await this.token.getPastVotes(this.other1, t2.timepoint)).to.equal(supply - 10n);
            expect(await this.token.getPastVotes(this.other1, t2.timepoint + 1n)).to.equal(supply - 10n);
            expect(await this.token.getPastVotes(this.other1, t3.timepoint)).to.equal(supply - 20n);
            expect(await this.token.getPastVotes(this.other1, t3.timepoint + 1n)).to.equal(supply - 20n);
            expect(await this.token.getPastVotes(this.other1, t4.timepoint)).to.equal(supply);
            expect(await this.token.getPastVotes(this.other1, t4.timepoint + 1n)).to.equal(supply);
          });
        });
      });

      describe('getPastTotalSupply', function () {
        beforeEach(async function () {
          await this.token.connect(this.holder).delegate(this.holder);
        });

        it('reverts if block number >= current block', async function () {
          const clock = await this.token.clock();
          await expect(this.token.getPastTotalSupply(50_000_000_000n))
            .to.be.revertedWithCustomError(this.token, 'ERC5805FutureLookup')
            .withArgs(50_000_000_000n, clock);
        });

        it('returns 0 if there are no checkpoints', async function () {
          expect(await this.token.getPastTotalSupply(0n)).to.equal(0n);
        });

        it('returns the latest block if >= last checkpoint block', async function () {
          const tx = await this.token.$_mint(this.holder, supply);
          const timepoint = await time.clockFromReceipt[mode](tx);
          await mine(2);

          expect(await this.token.getPastTotalSupply(timepoint)).to.equal(supply);
          expect(await this.token.getPastTotalSupply(timepoint + 1n)).to.equal(supply);
        });

        it('returns zero if < first checkpoint block', async function () {
          await mine();
          const tx = await this.token.$_mint(this.holder, supply);
          const timepoint = await time.clockFromReceipt[mode](tx);
          await mine(2);

          expect(await this.token.getPastTotalSupply(timepoint - 1n)).to.equal(0n);
          expect(await this.token.getPastTotalSupply(timepoint + 1n)).to.equal(supply);
        });

        it('generally returns the voting balance at the appropriate checkpoint', async function () {
          const t1 = await this.token.$_mint(this.holder, supply);
          await mine(2);
          const t2 = await this.token.$_burn(this.holder, 10n);
          await mine(2);
          const t3 = await this.token.$_burn(this.holder, 10n);
          await mine(2);
          const t4 = await this.token.$_mint(this.holder, 20n);
          await mine(2);

          t1.timepoint = await time.clockFromReceipt[mode](t1);
          t2.timepoint = await time.clockFromReceipt[mode](t2);
          t3.timepoint = await time.clockFromReceipt[mode](t3);
          t4.timepoint = await time.clockFromReceipt[mode](t4);

          expect(await this.token.getPastTotalSupply(t1.timepoint - 1n)).to.equal(0n);
          expect(await this.token.getPastTotalSupply(t1.timepoint)).to.equal(supply);
          expect(await this.token.getPastTotalSupply(t1.timepoint + 1n)).to.equal(supply);
          expect(await this.token.getPastTotalSupply(t2.timepoint)).to.equal(supply - 10n);
          expect(await this.token.getPastTotalSupply(t2.timepoint + 1n)).to.equal(supply - 10n);
          expect(await this.token.getPastTotalSupply(t3.timepoint)).to.equal(supply - 20n);
          expect(await this.token.getPastTotalSupply(t3.timepoint + 1n)).to.equal(supply - 20n);
          expect(await this.token.getPastTotalSupply(t4.timepoint)).to.equal(supply);
          expect(await this.token.getPastTotalSupply(t4.timepoint + 1n)).to.equal(supply);
        });
      });
    });
  }
});
