import { getContractAddressesForNetworkOrThrow } from '@0x/contract-addresses';
import { Coordinator } from '@0x/contract-artifacts';
import { schemas } from '@0x/json-schemas';
import { generatePseudoRandomSalt, signatureUtils } from '@0x/order-utils';
import { Order, SignedOrder, SignedZeroExTransaction, ZeroExTransaction } from '@0x/types';
import { BigNumber, fetchAsync } from '@0x/utils';
import { Web3Wrapper } from '@0x/web3-wrapper';
import { ContractAbi, SupportedProvider } from 'ethereum-types';
import * as HttpStatus from 'http-status-codes';
import { flatten } from 'lodash';

import { CoordinatorContract, CoordinatorRegistryContract, ExchangeContract } from '@0x/abi-gen-wrappers';

import { orderTxOptsSchema } from './schemas/order_tx_opts_schema';
import { txOptsSchema } from './schemas/tx_opts_schema';
import { CoordinatorTransaction, OrderTransactionOpts } from './types';
import { assert } from './utils/assert';
import {
    CoordinatorServerApprovalRawResponse,
    CoordinatorServerApprovalResponse,
    CoordinatorServerCancellationResponse,
    CoordinatorServerError,
    CoordinatorServerErrorMsg,
    CoordinatorServerResponse,
} from './utils/coordinator_server_types';
import { decorators } from './utils/decorators';
import { getAbiEncodedTransactionData } from './utils/getAbiEncodedTransactionData';

/**
 * This class includes all the functionality related to filling or cancelling orders through
 * the 0x V2 Coordinator extension contract.
 */
export class CoordinatorWrapper {
    public abi: ContractAbi = Coordinator.compilerOutput.abi;
    public networkId: number;
    public address: string;
    public exchangeAddress: string;
    public registryAddress: string;
    private readonly _web3Wrapper: Web3Wrapper;
    private readonly _contractInstance: CoordinatorContract;
    private readonly _registryInstance: CoordinatorRegistryContract;
    private readonly _exchangeInstance: ExchangeContract;
    private readonly _feeRecipientToEndpoint: { [feeRecipient: string]: string } = {};

    /**
     * Instantiate CoordinatorWrapper
     * @param web3Wrapper Web3Wrapper instance to use.
     * @param networkId Desired networkId.
     * @param address The address of the Coordinator contract. If undefined, will
     * default to the known address corresponding to the networkId.
     * @param exchangeAddress The address of the Exchange contract. If undefined, will
     * default to the known address corresponding to the networkId.
     * @param registryAddress The address of the CoordinatorRegistry contract. If undefined, will
     * default to the known address corresponding to the networkId.
     */
    constructor(
        provider: SupportedProvider,
        networkId: number,
        address?: string,
        exchangeAddress?: string,
        registryAddress?: string,
    ) {
        this.networkId = networkId;
        const contractAddresses = getContractAddressesForNetworkOrThrow(networkId);
        this.address = address === undefined ? contractAddresses.coordinator : address;
        this.exchangeAddress = exchangeAddress === undefined ? contractAddresses.coordinator : exchangeAddress;
        this.registryAddress = registryAddress === undefined ? contractAddresses.coordinatorRegistry : registryAddress;
        this._web3Wrapper = new Web3Wrapper(provider);

        this._contractInstance = new CoordinatorContract(
            this.address,
            this._web3Wrapper.getProvider(),
            this._web3Wrapper.getContractDefaults(),
        );
        this._registryInstance = new CoordinatorRegistryContract(
            this.registryAddress,
            this._web3Wrapper.getProvider(),
            this._web3Wrapper.getContractDefaults(),
        );
        this._exchangeInstance = new ExchangeContract(
            this.exchangeAddress,
            this._web3Wrapper.getProvider(),
            this._web3Wrapper.getContractDefaults(),
        );
    }

    /**
     * Fills a signed order with an amount denominated in baseUnits of the taker asset. Under-the-hood, this
     * method uses the `feeRecipientAddress` of the order to look up the coordinator server endpoint registered in the
     * coordinator registry contract. It requests a signature from that coordinator server before
     * submitting the order and signature as a 0x transaction to the coordinator extension contract. The coordinator extension
     * contract validates signatures and then fills the order via the Exchange contract.
     * @param   signedOrder           An object that conforms to the SignedOrder interface.
     * @param   takerAssetFillAmount  The amount of the order (in taker asset baseUnits) that you wish to fill.
     * @param   takerAddress          The user Ethereum address who would like to fill this order. Must be available via the supplied
     *                                Provider provided at instantiation.
     * @param   orderTransactionOpts  Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async fillOrderAsync(
        signedOrder: SignedOrder,
        takerAssetFillAmount: BigNumber,
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrder', signedOrder, schemas.signedOrderSchema);
        assert.isValidBaseUnitAmount('takerAssetFillAmount', takerAssetFillAmount);
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const data = this._getAbiEncodedTransactionData(
            'fillOrder',
            signedOrder,
            takerAssetFillAmount,
            signedOrder.signature,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, [signedOrder], orderTransactionOpts);
        return txHash;
    }

    /**
     * Attempts to fill a specific amount of an order. If the entire amount specified cannot be filled,
     * the fill order is abandoned.
     * @param   signedOrder          An object that conforms to the SignedOrder interface.
     * @param   takerAssetFillAmount The amount of the order (in taker asset baseUnits) that you wish to fill.
     * @param   takerAddress         The user Ethereum address who would like to fill this order. Must be available via the supplied
     *                               Provider provided at instantiation.
     * @param   orderTransactionOpts Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async fillOrKillOrderAsync(
        signedOrder: SignedOrder,
        takerAssetFillAmount: BigNumber,
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrder', signedOrder, schemas.signedOrderSchema);
        assert.isValidBaseUnitAmount('takerAssetFillAmount', takerAssetFillAmount);
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const data = this._getAbiEncodedTransactionData(
            'fillOrKillOrder',
            signedOrder,
            takerAssetFillAmount,
            signedOrder.signature,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, [signedOrder], orderTransactionOpts);
        return txHash;
    }

    /**
     * Batch version of fillOrderAsync. Executes multiple fills atomically in a single transaction.
     * Under-the-hood, this method uses the `feeRecipientAddress`s of the orders to looks up the coordinator server endpoints
     * registered in the coordinator registry contract. It requests a signature from each coordinator server before
     * submitting the orders and signatures as a 0x transaction to the coordinator extension contract, which validates the
     * signatures and then fills the order through the Exchange contract.
     * If any `feeRecipientAddress` in the batch is not registered to a coordinator server, the whole batch fails.
     * @param   signedOrders          An array of signed orders to fill.
     * @param   takerAssetFillAmounts The amounts of the orders (in taker asset baseUnits) that you wish to fill.
     * @param   takerAddress          The user Ethereum address who would like to fill these orders. Must be available via the supplied
     *                                Provider provided at instantiation.
     * @param   orderTransactionOpts  Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async batchFillOrdersAsync(
        signedOrders: SignedOrder[],
        takerAssetFillAmounts: BigNumber[],
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
        for (const takerAssetFillAmount of takerAssetFillAmounts) {
            assert.isBigNumber('takerAssetFillAmount', takerAssetFillAmount);
        }
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const signatures = signedOrders.map(o => o.signature);
        const data = this._getAbiEncodedTransactionData(
            'batchFillOrders',
            signedOrders,
            takerAssetFillAmounts,
            signatures,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, signedOrders, orderTransactionOpts);
        return txHash;
    }

    /**
     * No throw version of batchFillOrdersAsync
     * @param   signedOrders          An array of signed orders to fill.
     * @param   takerAssetFillAmounts The amounts of the orders (in taker asset baseUnits) that you wish to fill.
     * @param   takerAddress          The user Ethereum address who would like to fill these orders. Must be available via the supplied
     *                                Provider provided at instantiation.
     * @param   orderTransactionOpts  Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async batchFillOrdersNoThrowAsync(
        signedOrders: SignedOrder[],
        takerAssetFillAmounts: BigNumber[],
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
        for (const takerAssetFillAmount of takerAssetFillAmounts) {
            assert.isBigNumber('takerAssetFillAmount', takerAssetFillAmount);
        }
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const signatures = signedOrders.map(o => o.signature);
        const data = this._getAbiEncodedTransactionData(
            'batchFillOrdersNoThrow',
            signedOrders,
            takerAssetFillAmounts,
            signatures,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, signedOrders, orderTransactionOpts);
        return txHash;
    }

    /**
     * Batch version of fillOrKillOrderAsync. Executes multiple fills atomically in a single transaction.
     * @param   signedOrders          An array of signed orders to fill.
     * @param   takerAssetFillAmounts The amounts of the orders (in taker asset baseUnits) that you wish to fill.
     * @param   takerAddress          The user Ethereum address who would like to fill these orders. Must be available via the supplied
     *                                Provider provided at instantiation.
     * @param   orderTransactionOpts  Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async batchFillOrKillOrdersAsync(
        signedOrders: SignedOrder[],
        takerAssetFillAmounts: BigNumber[],
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
        for (const takerAssetFillAmount of takerAssetFillAmounts) {
            assert.isBigNumber('takerAssetFillAmount', takerAssetFillAmount);
        }
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const signatures = signedOrders.map(o => o.signature);
        const data = this._getAbiEncodedTransactionData(
            'batchFillOrKillOrders',
            signedOrders,
            takerAssetFillAmounts,
            signatures,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, signedOrders, orderTransactionOpts);
        return txHash;
    }

    /**
     * No throw version of marketBuyOrdersAsync
     * @param   signedOrders         An array of signed orders to fill.
     * @param   makerAssetFillAmount Maker asset fill amount.
     * @param   takerAddress         The user Ethereum address who would like to fill these orders. Must be available via the supplied
     *                               Provider provided at instantiation.
     * @param   orderTransactionOpts Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async marketBuyOrdersNoThrowAsync(
        signedOrders: SignedOrder[],
        makerAssetFillAmount: BigNumber,
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
        assert.isBigNumber('makerAssetFillAmount', makerAssetFillAmount);
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const signatures = signedOrders.map(o => o.signature);
        const data = this._getAbiEncodedTransactionData(
            'marketBuyOrdersNoThrow',
            signedOrders,
            makerAssetFillAmount,
            signatures,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, signedOrders, orderTransactionOpts);
        return txHash;
    }

    /**
     * No throw version of marketSellOrdersAsync
     * @param   signedOrders         An array of signed orders to fill.
     * @param   takerAssetFillAmount Taker asset fill amount.
     * @param   takerAddress         The user Ethereum address who would like to fill these orders. Must be available via the supplied
     *                               Provider provided at instantiation.
     * @param   orderTransactionOpts Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async marketSellOrdersNoThrowAsync(
        signedOrders: SignedOrder[],
        takerAssetFillAmount: BigNumber,
        takerAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
        assert.isBigNumber('takerAssetFillAmount', takerAssetFillAmount);
        assert.isETHAddressHex('takerAddress', takerAddress);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('takerAddress', takerAddress, this._web3Wrapper);

        const signatures = signedOrders.map(o => o.signature);
        const data = this._getAbiEncodedTransactionData(
            'marketSellOrdersNoThrow',
            signedOrders,
            takerAssetFillAmount,
            signatures,
        );
        const txHash = await this._handleFillsAsync(data, takerAddress, signedOrders, orderTransactionOpts);
        return txHash;
    }

    /**
     * Soft cancel a given order.
     * Soft cancels are recorded only on coordinator operator servers and do not involve an Ethereum transaction.
     * See [soft cancels](https://github.com/0xProject/0x-protocol-specification/blob/master/v2/coordinator-specification.md#soft-cancels).
     * @param   order           An object that conforms to the Order or SignedOrder interface. The order you would like to cancel.
     * @return  CoordinatorServerCancellationResponse. See [Cancellation Response](https://github.com/0xProject/0x-protocol-specification/blob/master/v2/coordinator-specification.md#response).
     */
    public async softCancelOrderAsync(order: Order | SignedOrder): Promise<CoordinatorServerCancellationResponse> {
        assert.doesConformToSchema('order', order, schemas.orderSchema);
        assert.isETHAddressHex('feeRecipientAddress', order.feeRecipientAddress);
        assert.isSenderAddressAsync('makerAddress', order.makerAddress, this._web3Wrapper);

        const data = this._getAbiEncodedTransactionData('cancelOrder', order);
        const transaction = await this._generateSignedZeroExTransactionAsync(data, order.makerAddress);
        const endpoint = await this._getServerEndpointOrThrowAsync(order.feeRecipientAddress);

        const response = await this._executeServerRequestAsync(transaction, order.makerAddress, endpoint);
        if (response.isError) {
            const approvedOrders = new Array();
            const cancellations = new Array();
            const errors = [
                {
                    ...response,
                    orders: [order],
                },
            ];
            throw new CoordinatorServerError(
                CoordinatorServerErrorMsg.CancellationFailed,
                approvedOrders,
                cancellations,
                errors,
            );
        } else {
            return response.body as CoordinatorServerCancellationResponse;
        }
    }

    /**
     * Batch version of softCancelOrderAsync. Requests multiple soft cancels
     * @param   orders                An array of orders to cancel.
     * @return  CoordinatorServerCancellationResponse. See [Cancellation Response](https://github.com/0xProject/0x-protocol-specification/blob/master/v2/coordinator-specification.md#response).
     */
    public async batchSoftCancelOrdersAsync(orders: SignedOrder[]): Promise<CoordinatorServerCancellationResponse[]> {
        assert.doesConformToSchema('orders', orders, schemas.ordersSchema);
        const makerAddress = getMakerAddressOrThrow(orders);
        assert.isSenderAddressAsync('makerAddress', makerAddress, this._web3Wrapper);
        const data = this._getAbiEncodedTransactionData('batchCancelOrders', orders);

        const serverEndpointsToOrders = await this._mapServerEndpointsToOrdersAsync(orders);

        // make server requests
        const errorResponses: CoordinatorServerResponse[] = [];
        const successResponses: CoordinatorServerCancellationResponse[] = [];
        const transaction = await this._generateSignedZeroExTransactionAsync(data, makerAddress);
        for (const endpoint of Object.keys(serverEndpointsToOrders)) {
            const response = await this._executeServerRequestAsync(transaction, makerAddress, endpoint);
            if (response.isError) {
                errorResponses.push(response);
            } else {
                successResponses.push(response.body as CoordinatorServerCancellationResponse);
            }
        }

        // if no errors
        if (errorResponses.length === 0) {
            return successResponses;
        } else {
            // lookup orders with errors
            const errorsWithOrders = errorResponses.map(resp => {
                const endpoint = resp.coordinatorOperator;
                const _orders = serverEndpointsToOrders[endpoint];
                return {
                    ...resp,
                    orders: _orders,
                };
            });

            const approvedOrders = new Array();
            const cancellations = successResponses;
            // return errors and approvals
            throw new CoordinatorServerError(
                CoordinatorServerErrorMsg.CancellationFailed,
                approvedOrders,
                cancellations,
                errorsWithOrders,
            );
        }
    }

    /**
     * Cancels an order on-chain by submitting an Ethereum transaction.
     * @param   order           An object that conforms to the Order or SignedOrder interface. The order you would like to cancel.
     * @param   orderTransactionOpts Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async hardCancelOrderAsync(
        order: Order | SignedOrder,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('order', order, schemas.orderSchema);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('makerAddress', order.makerAddress, this._web3Wrapper);

        const data = this._getAbiEncodedTransactionData('cancelOrder', order);
        const transaction = await this._generateSignedZeroExTransactionAsync(data, order.makerAddress);

        const approvalSignatures = new Array();
        const approvalExpirationTimeSeconds = new Array();
        const txHash = await this._submitCoordinatorTransactionAsync(
            transaction,
            order.makerAddress,
            transaction.signature,
            approvalExpirationTimeSeconds,
            approvalSignatures,
            orderTransactionOpts,
        );
        return txHash;
    }

    /**
     * Batch version of hardCancelOrderAsync. Cancels orders on-chain by submitting an Ethereum transaction.
     * Executes multiple cancels atomically in a single transaction.
     * @param   orders                An array of orders to cancel.
     * @param   orderTransactionOpts  Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async batchHardCancelOrdersAsync(
        orders: SignedOrder[],
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.doesConformToSchema('orders', orders, schemas.ordersSchema);
        const makerAddress = getMakerAddressOrThrow(orders);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('makerAddress', makerAddress, this._web3Wrapper);

        const data = this._getAbiEncodedTransactionData('batchCancelOrders', orders);
        const transaction = await this._generateSignedZeroExTransactionAsync(data, makerAddress);

        const approvalSignatures = new Array();
        const approvalExpirationTimeSeconds = new Array();
        const txHash = await this._submitCoordinatorTransactionAsync(
            transaction,
            makerAddress,
            transaction.signature,
            approvalExpirationTimeSeconds,
            approvalSignatures,
            orderTransactionOpts,
        );
        return txHash;
    }

    /**
     * Cancels orders on-chain by submitting an Ethereum transaction.
     * Cancels all orders created by makerAddress with a salt less than or equal to the targetOrderEpoch
     * and senderAddress equal to coordinator extension contract address.
     * @param   targetOrderEpoch             Target order epoch.
     * @param   senderAddress                Address that should send the transaction.
     * @param   orderTransactionOpts         Optional arguments this method accepts.
     * @return  Transaction hash.
     */
    @decorators.asyncZeroExErrorHandler
    public async hardCancelOrdersUpToAsync(
        targetOrderEpoch: BigNumber,
        senderAddress: string,
        orderTransactionOpts: OrderTransactionOpts = { shouldValidate: true },
    ): Promise<string> {
        assert.isBigNumber('targetOrderEpoch', targetOrderEpoch);
        assert.doesConformToSchema('orderTransactionOpts', orderTransactionOpts, orderTxOptsSchema, [txOptsSchema]);
        await assert.isSenderAddressAsync('senderAddress', senderAddress, this._web3Wrapper);

        const data = this._getAbiEncodedTransactionData('cancelOrdersUpTo', targetOrderEpoch);
        const transaction = await this._generateSignedZeroExTransactionAsync(data, senderAddress);

        const approvalSignatures = new Array();
        const approvalExpirationTimeSeconds = new Array();
        const txHash = await this._submitCoordinatorTransactionAsync(
            transaction,
            senderAddress,
            transaction.signature,
            approvalExpirationTimeSeconds,
            approvalSignatures,
            orderTransactionOpts,
        );
        return txHash;
    }

    /**
     * Validates that the 0x transaction has been approved by all of the feeRecipients that correspond to each order in the transaction's Exchange calldata.
     * Throws an error if the transaction approvals are not valid. Will not detect failures that would occur when the transaction is executed on the Exchange contract.
     * @param transaction 0x transaction containing salt, signerAddress, and data.
     * @param txOrigin Required signer of Ethereum transaction calling this function.
     * @param transactionSignature Proof that the transaction has been signed by the signer.
     * @param approvalExpirationTimeSeconds Array of expiration times in seconds for which each corresponding approval signature expires.
     * @param approvalSignatures Array of signatures that correspond to the feeRecipients of each order in the transaction's Exchange calldata.
     */
    public async assertValidCoordinatorApprovalsOrThrowAsync(
        transaction: ZeroExTransaction,
        txOrigin: string,
        transactionSignature: string,
        approvalExpirationTimeSeconds: BigNumber[],
        approvalSignatures: string[],
    ): Promise<void> {
        assert.doesConformToSchema('transaction', transaction, schemas.zeroExTransactionSchema);
        assert.isETHAddressHex('txOrigin', txOrigin);
        assert.isHexString('transactionSignature', transactionSignature);
        for (const expirationTime of approvalExpirationTimeSeconds) {
            assert.isBigNumber('expirationTime', expirationTime);
        }
        for (const approvalSignature of approvalSignatures) {
            assert.isHexString('approvalSignature', approvalSignature);
        }

        await this._contractInstance.assertValidCoordinatorApprovals.callAsync(
            transaction,
            txOrigin,
            transactionSignature,
            approvalExpirationTimeSeconds,
            approvalSignatures,
        );
    }

    /**
     * Recovers the address of a signer given a hash and signature.
     * @param hash Any 32 byte hash.
     * @param signature Proof that the hash has been signed by signer.
     * @returns Signer address.
     */
    public async getSignerAddressAsync(hash: string, signature: string): Promise<string> {
        assert.isHexString('hash', hash);
        assert.isHexString('signature', signature);
        const signerAddress = await this._contractInstance.getSignerAddress.callAsync(hash, signature);
        return signerAddress;
    }

    private _getAbiEncodedTransactionData<K extends keyof ExchangeContract>(methodName: K, ...args: any[]): string {
        return getAbiEncodedTransactionData(this._exchangeInstance, methodName, ...args);
    }

    private async _handleFillsAsync(
        data: string,
        takerAddress: string,
        signedOrders: SignedOrder[],
        orderTransactionOpts: OrderTransactionOpts,
    ): Promise<string> {
        const coordinatorOrders = signedOrders.filter(o => o.senderAddress === this.address);
        const serverEndpointsToOrders = await this._mapServerEndpointsToOrdersAsync(coordinatorOrders);

        // make server requests
        const errorResponses: CoordinatorServerResponse[] = [];
        const approvalResponses: CoordinatorServerResponse[] = [];
        const transaction = await this._generateSignedZeroExTransactionAsync(data, takerAddress);
        for (const endpoint of Object.keys(serverEndpointsToOrders)) {
            const response = await this._executeServerRequestAsync(transaction, takerAddress, endpoint);
            if (response.isError) {
                errorResponses.push(response);
            } else {
                approvalResponses.push(response);
            }
        }

        // if no errors
        if (errorResponses.length === 0) {
            // concatenate all approval responses
            const allApprovals = approvalResponses.map(resp =>
                formatRawResponse(resp.body as CoordinatorServerApprovalRawResponse),
            );

            const allSignatures = flatten(allApprovals.map(a => a.signatures));
            const allExpirationTimes = flatten(allApprovals.map(a => a.expirationTimeSeconds));

            // submit transaction with approvals
            const txHash = await this._submitCoordinatorTransactionAsync(
                transaction,
                takerAddress,
                transaction.signature,
                allExpirationTimes,
                allSignatures,
                orderTransactionOpts,
            );
            return txHash;
        } else {
            // format errors and approvals
            // concatenate approvals
            const notCoordinatorOrders = signedOrders.filter(o => o.senderAddress !== this.address);
            const approvedOrdersNested = approvalResponses.map(resp => {
                const endpoint = resp.coordinatorOperator;
                const orders = serverEndpointsToOrders[endpoint];
                return orders;
            });
            const approvedOrders = flatten(approvedOrdersNested.concat(notCoordinatorOrders));

            // lookup orders with errors
            const errorsWithOrders = errorResponses.map(resp => {
                const endpoint = resp.coordinatorOperator;
                const orders = serverEndpointsToOrders[endpoint];
                return {
                    ...resp,
                    orders,
                };
            });

            // throw informative error
            const cancellations = new Array();
            throw new CoordinatorServerError(
                CoordinatorServerErrorMsg.FillFailed,
                approvedOrders,
                cancellations,
                errorsWithOrders,
            );
        }
        function formatRawResponse(
            rawResponse: CoordinatorServerApprovalRawResponse,
        ): CoordinatorServerApprovalResponse {
            return {
                signatures: ([] as string[]).concat(rawResponse.signatures),
                expirationTimeSeconds: ([] as BigNumber[]).concat(
                    Array(rawResponse.signatures.length).fill(rawResponse.expirationTimeSeconds),
                ),
            };
        }
    }

    private async _getServerEndpointOrThrowAsync(feeRecipientAddress: string): Promise<string> {
        const cached = this._feeRecipientToEndpoint[feeRecipientAddress];
        const endpoint =
            cached !== undefined
                ? cached
                : await _fetchServerEndpointOrThrowAsync(feeRecipientAddress, this._registryInstance);
        return endpoint;

        async function _fetchServerEndpointOrThrowAsync(
            feeRecipient: string,
            registryInstance: CoordinatorRegistryContract,
        ): Promise<string> {
            const coordinatorOperatorEndpoint = await registryInstance.getCoordinatorEndpoint.callAsync(feeRecipient);
            if (coordinatorOperatorEndpoint === '' || coordinatorOperatorEndpoint === undefined) {
                throw new Error(
                    `No Coordinator server endpoint found in Coordinator Registry for feeRecipientAddress: ${feeRecipient}. Registry contract address: ${
                        registryInstance.address
                    }`,
                );
            }
            return coordinatorOperatorEndpoint;
        }
    }

    private async _generateSignedZeroExTransactionAsync(
        data: string,
        signerAddress: string,
    ): Promise<SignedZeroExTransaction> {
        const transaction: ZeroExTransaction = {
            salt: generatePseudoRandomSalt(),
            signerAddress,
            data,
            domain: {
                verifyingContract: this.exchangeAddress,
                chainId: await this._web3Wrapper.getChainIdAsync(),
            },
            // HACK (xianny): arbitrary numbers for now
            expirationTimeSeconds: new BigNumber(5),
            gasPrice: new BigNumber(1),
        };
        const signedTransaction = await signatureUtils.ecSignTransactionAsync(
            this._web3Wrapper.getProvider(),
            transaction,
            transaction.signerAddress,
        );

        return signedTransaction;
    }

    private async _executeServerRequestAsync(
        signedTransaction: SignedZeroExTransaction,
        txOrigin: string,
        endpoint: string,
    ): Promise<CoordinatorServerResponse> {
        const requestPayload = {
            signedTransaction,
            txOrigin,
        };
        const response = await fetchAsync(`${endpoint}/v1/request_transaction?networkId=${this.networkId}`, {
            body: JSON.stringify(requestPayload),
            method: 'POST',
            headers: {
                'Content-Type': 'application/json; charset=utf-8',
            },
        });

        const isError = response.status !== HttpStatus.OK;
        const isValidationError = response.status === HttpStatus.BAD_REQUEST;
        const json = isError && !isValidationError ? undefined : await response.json();

        const result = {
            isError,
            status: response.status,
            body: isError ? undefined : json,
            error: isError ? json : undefined,
            request: requestPayload,
            coordinatorOperator: endpoint,
        };

        return result;
    }

    private async _submitCoordinatorTransactionAsync(
        transaction: CoordinatorTransaction,
        txOrigin: string,
        transactionSignature: string,
        approvalExpirationTimeSeconds: BigNumber[],
        approvalSignatures: string[],
        orderTransactionOpts: OrderTransactionOpts,
    ): Promise<string> {
        if (orderTransactionOpts.shouldValidate) {
            await this._contractInstance.executeTransaction.callAsync(
                transaction,
                txOrigin,
                transactionSignature,
                approvalExpirationTimeSeconds,
                approvalSignatures,
                {
                    from: txOrigin,
                    gas: orderTransactionOpts.gasLimit,
                    gasPrice: orderTransactionOpts.gasPrice,
                    nonce: orderTransactionOpts.nonce,
                },
            );
        }
        const txHash = await this._contractInstance.executeTransaction.sendTransactionAsync(
            transaction,
            txOrigin,
            transactionSignature,
            approvalExpirationTimeSeconds,
            approvalSignatures,
            {
                from: txOrigin,
                gas: orderTransactionOpts.gasLimit,
                gasPrice: orderTransactionOpts.gasPrice,
                nonce: orderTransactionOpts.nonce,
            },
        );
        return txHash;
    }

    private async _mapServerEndpointsToOrdersAsync(
        coordinatorOrders: SignedOrder[],
    ): Promise<{ [endpoint: string]: SignedOrder[] }> {
        const feeRecipientsToOrders: { [feeRecipient: string]: SignedOrder[] } = {};
        for (const order of coordinatorOrders) {
            const feeRecipient = order.feeRecipientAddress;
            if (feeRecipientsToOrders[feeRecipient] === undefined) {
                feeRecipientsToOrders[feeRecipient] = [] as SignedOrder[];
            }
            feeRecipientsToOrders[feeRecipient].push(order);
        }
        const serverEndpointsToOrders: { [endpoint: string]: SignedOrder[] } = {};
        for (const feeRecipient of Object.keys(feeRecipientsToOrders)) {
            const endpoint = await this._getServerEndpointOrThrowAsync(feeRecipient);
            const orders = feeRecipientsToOrders[feeRecipient];
            if (serverEndpointsToOrders[endpoint] === undefined) {
                serverEndpointsToOrders[endpoint] = [];
            }
            serverEndpointsToOrders[endpoint] = serverEndpointsToOrders[endpoint].concat(orders);
        }
        return serverEndpointsToOrders;
    }
}

function getMakerAddressOrThrow(orders: Array<Order | SignedOrder>): string {
    const uniqueMakerAddresses = new Set(orders.map(o => o.makerAddress));
    if (uniqueMakerAddresses.size > 1) {
        throw new Error(`All orders in a batch must have the same makerAddress`);
    }
    return orders[0].makerAddress;
}

// tslint:disable:max-file-line-count
