import { BlockchainLifecycle, web3Factory } from '@0x/dev-utils';
import { RPCSubprovider, Web3ProviderEngine } from '@0x/subproviders';
import { providerUtils } from '@0x/utils';
import { TxData, Web3Wrapper } from '@0x/web3-wrapper';
import * as _ from 'lodash';
import * as mocha from 'mocha';
import * as process from 'process';

import { provider, providerConfigs, txDefaults, web3Wrapper } from './web3_wrapper';

// tslint:disable: no-namespace only-arrow-functions no-unbound-method max-classes-per-file

export type ISuite = mocha.ISuite;
export type ISuiteCallbackContext = mocha.ISuiteCallbackContext;
export type SuiteCallback = (this: ISuiteCallbackContext) => void;
export type ContextDefinitionCallback<T> = (description: string, callback: SuiteCallback) => T;
export type BlockchainSuiteCallback = (this: ISuiteCallbackContext, env: BlockchainTestsEnvironment) => void;
export type BlockchainContextDefinitionCallback<T> = (description: string, callback: BlockchainSuiteCallback) => T;
export interface ContextDefinition extends mocha.IContextDefinition {
    optional: ContextDefinitionCallback<ISuite | void>;
}

/**
 * `blockchainTests()` config options.
 */
export interface BlockchainContextConfig {
    fork: Partial<{
        // Accounts to unlock on ganache.
        unlockedAccounts: string[];
    }>;
}

let TEST_ENV_CONFIG: Partial<BlockchainContextConfig> = {};

/**
 * Interface for `blockchainTests()`.
 */
export interface BlockchainContextDefinition {
    (description: string, callback: BlockchainSuiteCallback): ISuite;
    configure: (config?: Partial<BlockchainContextConfig>) => void;
    only: BlockchainContextDefinitionCallback<ISuite>;
    skip: BlockchainContextDefinitionCallback<void>;
    optional: BlockchainContextDefinitionCallback<ISuite | void>;
    resets: BlockchainContextDefinitionCallback<ISuite | void> & {
        only: BlockchainContextDefinitionCallback<ISuite>;
        skip: BlockchainContextDefinitionCallback<void>;
        optional: BlockchainContextDefinitionCallback<ISuite | void>;
    };
    fork: BlockchainContextDefinitionCallback<ISuite | void> & {
        only: BlockchainContextDefinitionCallback<ISuite>;
        skip: BlockchainContextDefinitionCallback<void>;
        optional: BlockchainContextDefinitionCallback<ISuite | void>;
        resets: BlockchainContextDefinitionCallback<ISuite | void>;
    };
    live: BlockchainContextDefinitionCallback<ISuite | void> & {
        only: BlockchainContextDefinitionCallback<ISuite>;
        skip: BlockchainContextDefinitionCallback<void>;
        optional: BlockchainContextDefinitionCallback<ISuite | void>;
    };
}

/**
 * Describes the environment object passed into the `blockchainTests()` callback.
 */
export interface BlockchainTestsEnvironment {
    blockchainLifecycle: BlockchainLifecycle;
    provider: Web3ProviderEngine;
    txDefaults: Partial<TxData>;
    web3Wrapper: Web3Wrapper;
    getChainIdAsync(): Promise<number>;
    getAccountAddressesAsync(): Promise<string[]>;
}

class BlockchainTestsEnvironmentBase {
    public blockchainLifecycle!: BlockchainLifecycle;
    public provider!: Web3ProviderEngine;
    public txDefaults!: Partial<TxData>;
    public web3Wrapper!: Web3Wrapper;

    public async getChainIdAsync(): Promise<number> {
        return providerUtils.getChainIdAsync(this.provider);
    }

    public async getAccountAddressesAsync(): Promise<string[]> {
        return this.web3Wrapper.getAvailableAddressesAsync();
    }
}

interface BlockchainEnvironmentFactory {
    create(): BlockchainTestsEnvironment;
}

/**
 * `BlockchainTestsEnvironment` that uses the default ganache provider.
 */
export class StandardBlockchainTestsEnvironmentSingleton extends BlockchainTestsEnvironmentBase {
    private static _instance: StandardBlockchainTestsEnvironmentSingleton | undefined;

    // Create or retrieve the singleton instance of this class.
    public static create(): StandardBlockchainTestsEnvironmentSingleton {
        if (StandardBlockchainTestsEnvironmentSingleton._instance === undefined) {
            StandardBlockchainTestsEnvironmentSingleton._instance = new StandardBlockchainTestsEnvironmentSingleton();
        }
        return StandardBlockchainTestsEnvironmentSingleton._instance;
    }

    // Reset the singleton.
    public static reset(): void {
        StandardBlockchainTestsEnvironmentSingleton._instance = undefined;
    }

    // Get the singleton instance of this class.
    public static getInstance(): StandardBlockchainTestsEnvironmentSingleton | undefined {
        return StandardBlockchainTestsEnvironmentSingleton._instance;
    }

    protected constructor() {
        super();
        this.blockchainLifecycle = new BlockchainLifecycle(web3Wrapper);
        this.provider = provider;
        this.txDefaults = txDefaults;
        this.web3Wrapper = web3Wrapper;
    }
}

/**
 * `BlockchainTestsEnvironment` that uses a forked ganache provider.
 */
export class ForkedBlockchainTestsEnvironmentSingleton extends BlockchainTestsEnvironmentBase {
    private static _instance: ForkedBlockchainTestsEnvironmentSingleton | undefined;

    // Create or retrieve the singleton instance of this class.
    public static create(): ForkedBlockchainTestsEnvironmentSingleton {
        if (ForkedBlockchainTestsEnvironmentSingleton._instance === undefined) {
            ForkedBlockchainTestsEnvironmentSingleton._instance = new ForkedBlockchainTestsEnvironmentSingleton();
        }
        return ForkedBlockchainTestsEnvironmentSingleton._instance;
    }

    // Reset the singleton.
    public static reset(): void {
        ForkedBlockchainTestsEnvironmentSingleton._instance = undefined;
    }

    protected static _createWeb3Provider(forkHost: string): Web3ProviderEngine {
        const forkConfig = TEST_ENV_CONFIG.fork || {};
        const unlockedAccounts = forkConfig.unlockedAccounts;
        return web3Factory.getRpcProvider({
            ...providerConfigs,
            fork: forkHost,
            blockTime: 0,
            ...(unlockedAccounts ? { unlocked_accounts: unlockedAccounts } : {}),
        });
    }

    // Get the singleton instance of this class.
    public static getInstance(): ForkedBlockchainTestsEnvironmentSingleton | undefined {
        return ForkedBlockchainTestsEnvironmentSingleton._instance;
    }

    protected constructor() {
        super();
        this.txDefaults = txDefaults;
        this.provider = process.env.FORK_RPC_URL
            ? ForkedBlockchainTestsEnvironmentSingleton._createWeb3Provider(process.env.FORK_RPC_URL)
            : // Create a dummy provider if no RPC backend supplied.
              createDummyProvider();
        this.web3Wrapper = new Web3Wrapper(this.provider);
        this.blockchainLifecycle = new BlockchainLifecycle(this.web3Wrapper);
    }
}

/**
 * `BlockchainTestsEnvironment` that uses a live web3 provider.
 */
export class LiveBlockchainTestsEnvironmentSingleton extends BlockchainTestsEnvironmentBase {
    private static _instance: LiveBlockchainTestsEnvironmentSingleton | undefined;

    // Create or retrieve the singleton instance of this class.
    public static create(): LiveBlockchainTestsEnvironmentSingleton {
        if (LiveBlockchainTestsEnvironmentSingleton._instance === undefined) {
            LiveBlockchainTestsEnvironmentSingleton._instance = new LiveBlockchainTestsEnvironmentSingleton();
        }
        return LiveBlockchainTestsEnvironmentSingleton._instance;
    }

    // Reset the singleton.
    public static reset(): void {
        LiveBlockchainTestsEnvironmentSingleton._instance = undefined;
    }

    protected static _createWeb3Provider(rpcHost: string): Web3ProviderEngine {
        const providerEngine = new Web3ProviderEngine();
        providerEngine.addProvider(new RPCSubprovider(rpcHost));
        providerUtils.startProviderEngine(providerEngine);
        return providerEngine;
    }

    // Get the singleton instance of this class.
    public static getInstance(): LiveBlockchainTestsEnvironmentSingleton | undefined {
        return LiveBlockchainTestsEnvironmentSingleton._instance;
    }

    protected constructor() {
        super();
        this.txDefaults = txDefaults;
        this.provider = process.env.LIVE_RPC_URL
            ? LiveBlockchainTestsEnvironmentSingleton._createWeb3Provider(process.env.LIVE_RPC_URL)
            : // Create a dummy provider if no RPC backend supplied.
              createDummyProvider();
        this.web3Wrapper = new Web3Wrapper(this.provider);
        const snapshotHandlerAsync = async (): Promise<void> => {
            throw new Error('Snapshots are not supported with a live provider.');
        };
        this.blockchainLifecycle = {
            startAsync: snapshotHandlerAsync,
            revertAsync: snapshotHandlerAsync,
        } as any;
    }
}

// The original `describe()` global provided by mocha.
const mochaDescribe = (global as any).describe as mocha.IContextDefinition;

/**
 * An augmented version of mocha's `describe()`.
 */
export const describe = _.assign(mochaDescribe, {
    optional(description: string, callback: SuiteCallback): ISuite | void {
        const describeCall = process.env.TEST_ALL ? mochaDescribe : mochaDescribe.skip;
        return describeCall(description, callback);
    },
}) as ContextDefinition;

/**
 * Like mocha's `describe()`, but sets up a blockchain environment for you.
 */
export const blockchainTests: BlockchainContextDefinition = _.assign(
    function(description: string, callback: BlockchainSuiteCallback): ISuite {
        return defineBlockchainSuite(StandardBlockchainTestsEnvironmentSingleton, description, callback, describe);
    },
    {
        configure(config?: Partial<BlockchainContextConfig>): void {
            // Update the global config and reset all environment singletons.
            TEST_ENV_CONFIG = {
                ...TEST_ENV_CONFIG,
                ...config,
            };
            ForkedBlockchainTestsEnvironmentSingleton.reset();
            StandardBlockchainTestsEnvironmentSingleton.reset();
            LiveBlockchainTestsEnvironmentSingleton.reset();
        },
        only(description: string, callback: BlockchainSuiteCallback): ISuite {
            return defineBlockchainSuite(
                StandardBlockchainTestsEnvironmentSingleton,
                description,
                callback,
                describe.only,
            );
        },
        skip(description: string, callback: BlockchainSuiteCallback): void {
            return defineBlockchainSuite(
                StandardBlockchainTestsEnvironmentSingleton,
                description,
                callback,
                describe.skip,
            );
        },
        optional(description: string, callback: BlockchainSuiteCallback): ISuite | void {
            return defineBlockchainSuite(
                StandardBlockchainTestsEnvironmentSingleton,
                description,
                callback,
                process.env.TEST_ALL ? describe : describe.skip,
            );
        },
        fork: _.assign(
            function(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                return defineBlockchainSuite(
                    ForkedBlockchainTestsEnvironmentSingleton,
                    description,
                    callback,
                    process.env.FORK_RPC_URL ? describe : describe.skip,
                );
            },
            {
                only(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                    return defineBlockchainSuite(
                        ForkedBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        process.env.FORK_RPC_URL ? describe.only : describe.skip,
                    );
                },
                skip(description: string, callback: BlockchainSuiteCallback): void {
                    return defineBlockchainSuite(
                        ForkedBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        describe.skip,
                    );
                },
                optional(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                    return defineBlockchainSuite(
                        ForkedBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        process.env.FORK_RPC_URL ? describe.optional : describe.skip,
                    );
                },
                resets(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                    return defineResetsBlockchainSuite(
                        ForkedBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        process.env.FORK_RPC_URL ? describe : describe.skip,
                    );
                },
            },
        ),
        live: _.assign(
            function(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                return defineBlockchainSuite(
                    LiveBlockchainTestsEnvironmentSingleton,
                    description,
                    callback,
                    process.env.LIVE_RPC_URL ? describe : describe.skip,
                );
            },
            {
                only(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                    return defineBlockchainSuite(
                        LiveBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        process.env.LIVE_RPC_URL ? describe.only : describe.skip,
                    );
                },
                skip(description: string, callback: BlockchainSuiteCallback): void {
                    return defineBlockchainSuite(
                        LiveBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        describe.skip,
                    );
                },
                optional(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                    return defineBlockchainSuite(
                        LiveBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        process.env.LIVE_RPC_URL ? describe.optional : describe.skip,
                    );
                },
            },
        ),
        resets: _.assign(
            function(description: string, callback: BlockchainSuiteCallback): ISuite {
                return defineResetsBlockchainSuite(
                    StandardBlockchainTestsEnvironmentSingleton,
                    description,
                    callback,
                    describe,
                );
            },
            {
                only(description: string, callback: BlockchainSuiteCallback): ISuite {
                    return defineResetsBlockchainSuite(
                        StandardBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        describe.only,
                    );
                },
                skip(description: string, callback: BlockchainSuiteCallback): void {
                    return defineResetsBlockchainSuite(
                        StandardBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        describe.skip,
                    );
                },
                optional(description: string, callback: BlockchainSuiteCallback): ISuite | void {
                    return defineResetsBlockchainSuite(
                        StandardBlockchainTestsEnvironmentSingleton,
                        description,
                        callback,
                        describe.optional,
                    );
                },
            },
        ),
    },
) as BlockchainContextDefinition;

function defineBlockchainSuite<T>(
    envFactory: BlockchainEnvironmentFactory,
    description: string,
    callback: BlockchainSuiteCallback,
    describeCall: ContextDefinitionCallback<T>,
): T {
    return describeCall(description, function(this: ISuiteCallbackContext): void {
        callback.call(this, envFactory.create());
    });
}

function defineResetsBlockchainSuite<T>(
    envFactory: BlockchainEnvironmentFactory,
    description: string,
    callback: BlockchainSuiteCallback,
    describeCall: ContextDefinitionCallback<T>,
): T {
    return describeCall(description, function(this: ISuiteCallbackContext): void {
        const env = envFactory.create();
        beforeEach(async () => env.blockchainLifecycle.startAsync());
        afterEach(async () => env.blockchainLifecycle.revertAsync());
        callback.call(this, env);
    });
}

function createDummyProvider(): Web3ProviderEngine {
    return {
        addProvider: _.noop,
        on: _.noop,
        send: _.noop,
        sendAsync: _.noop,
        start: _.noop,
        stop: _.noop,
    };
}
