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

const { generators } = require('../helpers/random');

const shouldBehaveLikeClone = require('./Clones.behaviour');

const cloneInitCode = (instance, args = undefined) =>
  args
    ? ethers.concat([
        '0x61',
        ethers.toBeHex(0x2d + ethers.getBytes(args).length, 2),
        '0x3d81600a3d39f3363d3d373d3d3d363d73',
        instance.target ?? instance.address ?? instance,
        '0x5af43d82803e903d91602b57fd5bf3',
        args,
      ])
    : ethers.concat([
        '0x3d602d80600a3d3981f3363d3d373d3d3d363d73',
        instance.target ?? instance.address ?? instance,
        '0x5af43d82803e903d91602b57fd5bf3',
      ]);

async function fixture() {
  const [deployer] = await ethers.getSigners();

  const factory = await ethers.deployContract('$Clones');
  const implementation = await ethers.deployContract('DummyImplementation');

  const newClone =
    args =>
    async (opts = {}) => {
      const clone = await (args
        ? factory.$cloneWithImmutableArgs.staticCall(implementation, args)
        : factory.$clone.staticCall(implementation)
      ).then(address => implementation.attach(address));
      const tx = await (args
        ? opts.deployValue
          ? factory.$cloneWithImmutableArgs(implementation, args, ethers.Typed.uint256(opts.deployValue))
          : factory.$cloneWithImmutableArgs(implementation, args)
        : opts.deployValue
        ? factory.$clone(implementation, ethers.Typed.uint256(opts.deployValue))
        : factory.$clone(implementation));
      if (opts.initData || opts.initValue) {
        await deployer.sendTransaction({ to: clone, value: opts.initValue ?? 0n, data: opts.initData ?? '0x' });
      }
      return Object.assign(clone, { deploymentTransaction: () => tx });
    };

  const newCloneDeterministic =
    args =>
    async (opts = {}) => {
      const salt = opts.salt ?? ethers.randomBytes(32);
      const clone = await (args
        ? factory.$cloneDeterministicWithImmutableArgs.staticCall(implementation, args, salt)
        : factory.$cloneDeterministic.staticCall(implementation, salt)
      ).then(address => implementation.attach(address));
      const tx = await (args
        ? opts.deployValue
          ? factory.$cloneDeterministicWithImmutableArgs(
              implementation,
              args,
              salt,
              ethers.Typed.uint256(opts.deployValue),
            )
          : factory.$cloneDeterministicWithImmutableArgs(implementation, args, salt)
        : opts.deployValue
        ? factory.$cloneDeterministic(implementation, salt, ethers.Typed.uint256(opts.deployValue))
        : factory.$cloneDeterministic(implementation, salt));
      if (opts.initData || opts.initValue) {
        await deployer.sendTransaction({ to: clone, value: opts.initValue ?? 0n, data: opts.initData ?? '0x' });
      }
      return Object.assign(clone, { deploymentTransaction: () => tx });
    };

  return { deployer, factory, implementation, newClone, newCloneDeterministic };
}

describe('Clones', function () {
  beforeEach(async function () {
    Object.assign(this, await loadFixture(fixture));
  });

  for (const args of [undefined, '0x', '0x11223344']) {
    describe(args ? `with immutable args: ${args}` : 'without immutable args', function () {
      describe('clone', function () {
        beforeEach(async function () {
          this.createClone = this.newClone(args);
        });

        shouldBehaveLikeClone();

        it('get immutable arguments', async function () {
          const instance = await this.createClone();
          expect(await this.factory.$fetchCloneArgs(instance)).to.equal(args ?? '0x');
        });
      });

      describe('cloneDeterministic', function () {
        beforeEach(async function () {
          this.createClone = this.newCloneDeterministic(args);
        });

        shouldBehaveLikeClone();

        it('get immutable arguments', async function () {
          const instance = await this.createClone();
          expect(await this.factory.$fetchCloneArgs(instance)).to.equal(args ?? '0x');
        });

        it('revert if address already used', async function () {
          const salt = ethers.randomBytes(32);

          const deployClone = () =>
            args
              ? this.factory.$cloneDeterministicWithImmutableArgs(this.implementation, args, salt)
              : this.factory.$cloneDeterministic(this.implementation, salt);

          // deploy once
          await expect(deployClone()).to.not.be.reverted;

          // deploy twice
          await expect(deployClone()).to.be.revertedWithCustomError(this.factory, 'FailedDeployment');
        });

        it('address prediction', async function () {
          const salt = ethers.randomBytes(32);

          const expected = ethers.getCreate2Address(
            this.factory.target,
            salt,
            ethers.keccak256(cloneInitCode(this.implementation, args)),
          );

          if (args) {
            const predicted = await this.factory.$predictDeterministicAddressWithImmutableArgs(
              this.implementation,
              args,
              salt,
            );
            expect(predicted).to.equal(expected);

            await expect(this.factory.$cloneDeterministicWithImmutableArgs(this.implementation, args, salt))
              .to.emit(this.factory, 'return$cloneDeterministicWithImmutableArgs_address_bytes_bytes32')
              .withArgs(predicted);
          } else {
            const predicted = await this.factory.$predictDeterministicAddress(this.implementation, salt);
            expect(predicted).to.equal(expected);

            await expect(this.factory.$cloneDeterministic(this.implementation, salt))
              .to.emit(this.factory, 'return$cloneDeterministic_address_bytes32')
              .withArgs(predicted);
          }
        });
      });
    });
  }

  it('EIP-170 limit on immutable args', async function () {
    // EIP-170 limits the contract code size to 0x6000
    // This limits the length of immutable args to 0x5fd3
    const args = generators.hexBytes(0x5fd4);
    const salt = ethers.randomBytes(32);

    await expect(
      this.factory.$predictDeterministicAddressWithImmutableArgs(this.implementation, args, salt),
    ).to.be.revertedWithCustomError(this.factory, 'CloneArgumentsTooLong');

    await expect(this.factory.$cloneWithImmutableArgs(this.implementation, args)).to.be.revertedWithCustomError(
      this.factory,
      'CloneArgumentsTooLong',
    );
  });
});
