import BigNumber from "bignumber.js";
import BN from "bn.js";
import chalk from "chalk";
import { randomBytes } from "crypto";
import { BaseContract, ContractFactory, ContractTransaction } from "ethers";
import { getAddress, keccak256 } from "ethers/lib/utils";
import { CallOptions, DeployFunction } from "hardhat-deploy/types";
import { HardhatRuntimeEnvironment } from "hardhat/types";

import {
    readValidations,
    withDefaults,
} from "@openzeppelin/hardhat-upgrades/dist/utils";
import {
    assertStorageUpgradeSafe,
    assertUpgradeSafe,
    getImplementationAddress,
    getStorageLayout,
    getStorageLayoutForAddress,
    getUnlinkedBytecode,
    getVersion,
    Manifest,
    setProxyKind,
} from "@openzeppelin/upgrades-core";
import { SyncOrPromise } from "@renproject/utils";

import {
    AccessControlEnumerableUpgradeable,
    ERC20,
    Ownable,
    RenProxyAdmin,
    TransparentUpgradeableProxy,
    TransparentUpgradeableProxy__factory,
} from "../typechain";
import { NetworkConfig, networks } from "./networks";

export const Ox0 = "0x0000000000000000000000000000000000000000";
export const CREATE2_DEPLOYER = "0x2222229fb3318a6375fa78fd299a9a42ac6a8fbf";
export interface ConsoleInterface {
    log(message?: any, ...optionalParams: any[]): void;
    error(message?: any, ...optionalParams: any[]): void;
}

export const create2Salt = (network: string) => `REN.RC1`; // Release Candidate 1

export const getContractAt =
    (hre: HardhatRuntimeEnvironment) =>
    async <T extends BaseContract>(name: string, address: string) => {
        const { getNamedAccounts, ethers } = hre;
        const { deployer } = await getNamedAccounts();
        return await ethers.getContractAt<T>(name, address, deployer);
    };

export const setupGetExistingDeployment =
    (hre: HardhatRuntimeEnvironment) =>
    async <T extends BaseContract>(name: string): Promise<T | null> => {
        const { deployments, getNamedAccounts, ethers, network } = hre;
        const { getOrNull } = deployments;

        if (network.name === "hardhat") {
            return null;
        }

        const { deployer } = await getNamedAccounts();
        const result = await getOrNull(name);

        return result ? await ethers.getContractAt<T>(name, result.address, deployer) : result;
    };

export const setupDeploy =
    (hre: HardhatRuntimeEnvironment, logger: ConsoleInterface = console) =>
    async <
        F extends ContractFactory,
        C extends F extends { deploy: (...args: any) => Promise<infer C> } ? C : never = F extends {
            deploy: (...args: any) => Promise<infer C>;
        }
            ? C
            : never
    >(
        name: string,
        args: F extends { deploy: (...args: infer X) => Promise<any> } ? X : never,
        overrides?: CallOptions
    ): Promise<F extends { deploy: (...args: any) => Promise<infer C> } ? C : never> => {
        const { deployments, getNamedAccounts, ethers, network } = hre;
        const { deploy } = deployments;

        const { deployer } = await getNamedAccounts();
        logger.log(`Deploying ${name} from ${deployer}`);

        const result = await deploy(name, {
            from: deployer,
            args: args,
            log: true,
            skipIfAlreadyDeployed: false,
            ...overrides,
        });
        logger.log(`Deployed ${name} at ${result.address}.`);
        const contract = await ethers.getContractAt<C>(name, result.address, deployer);

        let owner;
        try {
            owner = await (contract as any as Ownable).owner();
        } catch (error) {}
        if (owner && owner.toLowerCase() === CREATE2_DEPLOYER) {
            throw new Error(`${name}'s owner was initialized to CREATE2 deployer!`);
        }

        let roleAdmin;
        try {
            roleAdmin = await (contract as any as AccessControlEnumerableUpgradeable).getRoleAdmin(
                await (contract as any as AccessControlEnumerableUpgradeable).DEFAULT_ADMIN_ROLE()
            );
        } catch (error) {}
        if (roleAdmin && roleAdmin.toLowerCase() === CREATE2_DEPLOYER) {
            throw new Error(`${name}'s role admin was initialized to CREATE2 deployer!`);
        }

        return contract;
    };

export const setupCreate2 =
    (hre: HardhatRuntimeEnvironment, create2SaltOverride?: string, logger: ConsoleInterface = console) =>
    async <
        F extends ContractFactory,
        C extends F extends { deploy: (...args: any) => Promise<infer C> } ? C : never = F extends {
            deploy: (...args: any) => Promise<infer C>;
        }
            ? C
            : never
    >(
        name: string,
        args: F extends { deploy: (...args: infer X) => Promise<any> } ? X : never,
        overrides?: CallOptions,
        create2SaltOverrideAlt?: string
    ): Promise<F extends { deploy: (...args: any) => Promise<infer C> } ? C : never> => {
        const { deployments, getNamedAccounts, ethers, network } = hre;
        const { deploy } = deployments;

        const { deployer } = await getNamedAccounts();
        const salt = keccak256(Buffer.from(create2SaltOverrideAlt || create2SaltOverride || create2Salt(network.name)));
        logger.log(`Deploying ${name} from ${deployer} (salt: keccak256(${create2Salt(network.name)}))`);

        const result = await deploy(name, {
            from: deployer,
            args: args,
            log: true,
            deterministicDeployment: salt,
            skipIfAlreadyDeployed: true,
            ...overrides,
        });
        logger.log(`Deployed ${name} at ${result.address}.`);
        const contract = await ethers.getContractAt<C>(name, result.address, deployer);

        let owner;
        try {
            owner = await (contract as any as Ownable).owner();
        } catch (error) {}
        if (owner && owner.toLowerCase() === CREATE2_DEPLOYER) {
            throw new Error(`${name}'s owner was initialized to CREATE2 deployer!`);
        }

        let roleAdmin;
        try {
            roleAdmin = await (contract as any as AccessControlEnumerableUpgradeable).getRoleAdmin(
                await (contract as any as AccessControlEnumerableUpgradeable).DEFAULT_ADMIN_ROLE()
            );
        } catch (error) {}
        if (roleAdmin && roleAdmin.toLowerCase() === CREATE2_DEPLOYER) {
            throw new Error(`${name}'s role admin was initialized to CREATE2 deployer!`);
        }

        return contract;
    };

export const setupDeployProxy =
    (
        hre: HardhatRuntimeEnvironment,
        create2: ReturnType<typeof setupCreate2>,
        proxyAdmin: RenProxyAdmin,
        logger: ConsoleInterface = console
    ) =>
    async <
        F extends ContractFactory,
        C extends F extends { deploy: () => Promise<infer C> } ? C : never = F extends {
            deploy: () => Promise<infer C>;
        }
            ? C
            : never
    >(
        contractName: string,
        proxyName: string,
        options: {
            initializer: keyof C["callStatic"];
            constructorArgs: unknown[];
            kind?: "transparent";
        },
        isInitialized: (t: C) => SyncOrPromise<boolean>,
        create2SaltOverrideAlt?: string
    ): Promise<C> => {
        const { getNamedAccounts, ethers } = hre;

        const { deployer } = await getNamedAccounts();

        const { initializer, constructorArgs } = options;

        const waitForTx = setupWaitForTx(logger);

        const existingProxyDeployment = await setupGetExistingDeployment(hre)<TransparentUpgradeableProxy>(proxyName);
        let proxy: TransparentUpgradeableProxy;

        // openzeppelin-upgrades checks ////////////////////////////////////////
        const { provider } = hre.network;
        const manifest = await Manifest.forNetwork(provider);
        const ImplFactory = await ethers.getContractFactory(contractName);
        const validations = await readValidations(hre);
        const unlinkedBytecode = getUnlinkedBytecode(validations, ImplFactory.bytecode);
        const encodedArgs = ImplFactory.interface.encodeDeploy([]);
        const version = getVersion(unlinkedBytecode, ImplFactory.bytecode, encodedArgs);
        const layout = getStorageLayout(validations, version);
        const fullOpts = withDefaults(options);
        assertUpgradeSafe(validations, version, fullOpts);
        if (existingProxyDeployment) {
            await setProxyKind(provider, existingProxyDeployment.address, options);
            const currentImplAddress = await getImplementationAddress(provider, existingProxyDeployment.address);
            const currentLayout = await getStorageLayoutForAddress(manifest, validations, currentImplAddress);
            assertStorageUpgradeSafe(currentLayout, layout, fullOpts);
        }
        ////////////////////////////////////////////////////////////////////////

        const implementation = (await create2<F>(
            contractName,
            [] as any,
            undefined,
            create2SaltOverrideAlt
        )) as unknown as C;

        // openzeppelin-upgrades manifest //////////////////////////////////////
        await manifest.lockedRun(async () => {
            let data = await manifest.read();
            data.admin = {
                address: proxyAdmin.address,
            };
            data.impls[version.linkedWithoutMetadata] = {
                address: implementation.address,
                layout,
            };
            manifest.write(data);
        });
        ////////////////////////////////////////////////////////////////////////

        if (!(await isInitialized(implementation))) {
            logger.log(`Initializing ${contractName} instance (optional).`);
            await waitForTx(implementation[initializer](...constructorArgs));
        }

        if (existingProxyDeployment) {
            logger.log(`Reusing ${proxyName} at ${existingProxyDeployment.address}`);

            proxy = existingProxyDeployment;
            const currentImplementation = await proxyAdmin.getProxyImplementation(proxy.address);
            logger.log(proxyName, "points to", currentImplementation);
            if (currentImplementation.toLowerCase() !== implementation.address.toLowerCase()) {
                logger.log(
                    `Updating ${proxyName} to point to ${implementation.address} instead of ${currentImplementation}`
                );
                await waitForTx(proxyAdmin.upgrade(proxy.address, implementation.address));
            }
        } else {
            proxy = await create2<TransparentUpgradeableProxy__factory>(
                proxyName,
                [implementation.address, proxyAdmin.address, []],
                undefined,
                create2SaltOverrideAlt
            );

            // openzeppelin-upgrades manifest //////////////////////////////////
            await manifest.addProxy({ ...proxy, kind: "transparent" });
            ////////////////////////////////////////////////////////////////////
        }
        const final = await ethers.getContractAt<C>(contractName, proxy.address, deployer);

        if (!(await isInitialized(final))) {
            logger.log(`Initializing ${contractName} proxy.`);
            const initData: string = final.interface.encodeFunctionData(initializer as string, constructorArgs);
            await waitForTx(
                ethers.provider.getSigner().sendTransaction({
                    from: deployer,
                    to: final.address,
                    data: initData,
                })
            );
        }

        if (!(await isInitialized(final))) {
            throw new Error(`Invalid 'isInitialized' method.`);
        }

        return final;
    };

/**
 * Submits a transaction and waits for it to be mined.
 */
export const setupWaitForTx =
    (logger: ConsoleInterface = console) =>
    async (txOrPromise: Promise<ContractTransaction> | ContractTransaction, msg?: string) => {
        if (msg) {
            logger.log(msg);
        }
        const tx = await txOrPromise;
        process.stdout.write(`Waiting for ${tx.hash}...`);
        await tx.wait();
        // Clear the line
        process.stdout.write(`\x1B[2K\r`);
        logger.log(`Transaction confirmed: ${tx.hash}`);
    };

export const fixNonce = async (hre: HardhatRuntimeEnvironment, nonce: number) => {
    const { getNamedAccounts, ethers } = hre;
    const { deployer } = await getNamedAccounts();

    let tx = await ethers.provider.getSigner().sendTransaction({
        from: deployer,
        to: deployer,
        nonce: nonce,
        value: 0,
        gasPrice: 2000000000,
        gasLimit: 22000,
    });
    await tx.wait();
};

export const forwardBalance = async (hre: HardhatRuntimeEnvironment, to: string) => {
    const { getNamedAccounts, ethers } = hre;
    const { deployer } = await getNamedAccounts();
    const signer = await ethers.getSigner(deployer);
    const balance = new BigNumber((await ethers.provider.getBalance(deployer)).toString());
    const tx = await signer.sendTransaction({
        from: deployer,
        to,
        value: "0x" + new BN(balance.minus(0.01 * 1e18).toFixed()).toArrayLike(Buffer, "be", 32).toString("hex"),
    });
    console.log(
        `Sending ${balance
            .minus(0.01 * 1e18)
            .shiftedBy(-18)
            .toFixed()}: ${tx.hash}`
    );
    await tx.wait();
};

export const randomAddress = (): string => getAddress("0x" + randomBytes(20).toString("hex"));

export const forwardTokens =
    (hre: HardhatRuntimeEnvironment, config?: NetworkConfig, logger: ConsoleInterface = console) =>
    async (to: string) => {
        const { getNamedAccounts, ethers, network } = hre;

        logger.log(`Deploying to ${network.name}...`);

        const Ox = ethers.utils.getAddress;

        config = config || networks[network.name as "hardhat"];
        if (!config) {
            throw new Error(`No network configuration found for ${network.name}!`);
        }

        const chainId: number = (await ethers.provider.getNetwork()).chainId;
        const { deployer } = await getNamedAccounts();

        const waitForTx = setupWaitForTx(logger);

        // Test Tokens are deployed when a particular lock-asset doesn't exist on a
        // testnet.
        console.log(`Handling ${(config.lockGateways || []).length} lock assets.`);
        for (const { symbol, gateway, token, decimals } of config.lockGateways || []) {
            console.log(chalk.yellow(`Lock asset: ${symbol}`));
            // Check token symbol and decimals
            if (token && typeof token === "string") {
                const erc20 = await ethers.getContractAt<ERC20>("ERC20", token);
                const recipient = "0xFB87bCF203b78d9B67719b7EEa3b6B65A208961B";
                const deployerBalance = new BigNumber((await erc20.balanceOf(deployer)).toString());
                const recipientBalance = new BigNumber((await erc20.balanceOf(recipient)).toString());
                const decimals = await erc20.decimals();
                const amount = BigNumber.min(
                    deployerBalance.dividedBy(10),
                    new BigNumber(10000).shiftedBy(decimals)
                ).minus(recipientBalance);
                if (amount.isGreaterThan(0)) {
                    console.log(`Transferring ${amount.shiftedBy(-decimals).toFixed()} ${symbol}`);
                    await waitForTx(erc20.transfer(recipient, amount.toFixed()));
                } else {
                    console.log(`Skipping ${symbol}`);
                }
            }
        }
    };

const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {};
func.tags = [];
export default func;