import { schemas, SchemaValidator } from '@0x/json-schemas';
import { assetDataUtils, orderCalculationUtils, SignedOrder } from '@0x/order-utils';
import { RFQTFirmQuote, RFQTIndicativeQuote, TakerRequestQueryParams } from '@0x/quote-server';
import { ERC20AssetData } from '@0x/types';
import { BigNumber } from '@0x/utils';
import Axios, { AxiosInstance } from 'axios';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';

import { constants } from '../constants';
import { LogFunction, MarketOperation, RfqtMakerAssetOfferings, RfqtRequestOpts } from '../types';

import { ONE_SECOND_MS } from './market_operation_utils/constants';
import { RfqMakerBlacklist } from './rfq_maker_blacklist';

// tslint:disable-next-line: custom-no-magic-numbers
const KEEP_ALIVE_TTL = 5 * 60 * ONE_SECOND_MS;

export const quoteRequestorHttpClient: AxiosInstance = Axios.create({
    httpAgent: new HttpAgent({ keepAlive: true, timeout: KEEP_ALIVE_TTL }),
    httpsAgent: new HttpsAgent({ keepAlive: true, timeout: KEEP_ALIVE_TTL }),
});

const MAKER_TIMEOUT_STREAK_LENGTH = 10;
const MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES = 10;
const rfqMakerBlacklist = new RfqMakerBlacklist(MAKER_TIMEOUT_STREAK_LENGTH, MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES);

/**
 * Request quotes from RFQ-T providers
 */

function getTokenAddressOrThrow(assetData: string): string {
    const decodedAssetData = assetDataUtils.decodeAssetDataOrThrow(assetData);
    if (decodedAssetData.hasOwnProperty('tokenAddress')) {
        // type cast necessary here as decodeAssetDataOrThrow returns
        // an AssetData object, which doesn't necessarily contain a
        // token address.  (it could possibly be a StaticCallAssetData,
        // which lacks an address.)  so we'll just assume it's a token
        // here.  should be safe, with the enclosing guard condition
        // and subsequent error.
        // tslint:disable-next-line:no-unnecessary-type-assertion
        return (decodedAssetData as ERC20AssetData).tokenAddress;
    }
    throw new Error(`Decoded asset data (${JSON.stringify(decodedAssetData)}) does not contain a token address`);
}

function hasExpectedAssetData(
    expectedMakerAssetData: string,
    expectedTakerAssetData: string,
    makerAssetDataInQuestion: string,
    takerAssetDataInQuestion: string,
): boolean {
    const hasExpectedMakerAssetData = makerAssetDataInQuestion.toLowerCase() === expectedMakerAssetData.toLowerCase();
    const hasExpectedTakerAssetData = takerAssetDataInQuestion.toLowerCase() === expectedTakerAssetData.toLowerCase();
    return hasExpectedMakerAssetData && hasExpectedTakerAssetData;
}

function convertIfAxiosError(error: any): Error | object /* axios' .d.ts has AxiosError.toJSON() returning object */ {
    if (error.hasOwnProperty('isAxiosError') && error.isAxiosError) {
        const { message, name, config } = error;
        const { headers, timeout, httpsAgent } = config;
        const { keepAlive, keepAliveMsecs, sockets } = httpsAgent;

        const socketCounts: { [key: string]: number } = {};
        for (const socket of Object.keys(sockets)) {
            socketCounts[socket] = sockets[socket].length;
        }

        return {
            message,
            name,
            config: {
                headers,
                timeout,
                httpsAgent: {
                    keepAlive,
                    keepAliveMsecs,
                    socketCounts,
                },
            },
        };
    } else {
        return error;
    }
}

export class QuoteRequestor {
    private readonly _schemaValidator: SchemaValidator = new SchemaValidator();
    private readonly _orderSignatureToMakerUri: { [orderSignature: string]: string } = {};

    public static makeQueryParameters(
        takerAddress: string,
        marketOperation: MarketOperation,
        makerAssetData: string,
        takerAssetData: string,
        assetFillAmount: BigNumber,
        comparisonPrice?: BigNumber,
    ): TakerRequestQueryParams {
        const buyTokenAddress = getTokenAddressOrThrow(makerAssetData);
        const sellTokenAddress = getTokenAddressOrThrow(takerAssetData);
        const { buyAmountBaseUnits, sellAmountBaseUnits } =
            marketOperation === MarketOperation.Buy
                ? {
                      buyAmountBaseUnits: assetFillAmount,
                      sellAmountBaseUnits: undefined,
                  }
                : {
                      sellAmountBaseUnits: assetFillAmount,
                      buyAmountBaseUnits: undefined,
                  };

        const requestParamsWithBigNumbers: Pick<
            TakerRequestQueryParams,
            'buyTokenAddress' | 'sellTokenAddress' | 'takerAddress' | 'comparisonPrice'
        > = {
            takerAddress,
            comparisonPrice: comparisonPrice === undefined ? undefined : comparisonPrice.toString(),
            buyTokenAddress,
            sellTokenAddress,
        };

        // convert BigNumbers to strings
        // so they are digestible by axios
        if (sellAmountBaseUnits) {
            return {
                ...requestParamsWithBigNumbers,
                sellAmountBaseUnits: sellAmountBaseUnits.toString(),
            };
        } else if (buyAmountBaseUnits) {
            return {
                ...requestParamsWithBigNumbers,
                buyAmountBaseUnits: buyAmountBaseUnits.toString(),
            };
        } else {
            throw new Error('Neither "buyAmountBaseUnits" or "sellAmountBaseUnits" were defined');
        }
    }

    constructor(
        private readonly _rfqtAssetOfferings: RfqtMakerAssetOfferings,
        private readonly _warningLogger: LogFunction = constants.DEFAULT_WARNING_LOGGER,
        private readonly _infoLogger: LogFunction = constants.DEFAULT_INFO_LOGGER,
        private readonly _expiryBufferMs: number = constants.DEFAULT_SWAP_QUOTER_OPTS.expiryBufferMs,
    ) {
        rfqMakerBlacklist.infoLogger = this._infoLogger;
    }

    public async requestRfqtFirmQuotesAsync(
        makerAssetData: string,
        takerAssetData: string,
        assetFillAmount: BigNumber,
        marketOperation: MarketOperation,
        comparisonPrice: BigNumber | undefined,
        options: RfqtRequestOpts,
    ): Promise<RFQTFirmQuote[]> {
        const _opts: RfqtRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options };
        if (
            _opts.takerAddress === undefined ||
            _opts.takerAddress === '' ||
            _opts.takerAddress === '0x' ||
            !_opts.takerAddress ||
            _opts.takerAddress === constants.NULL_ADDRESS
        ) {
            throw new Error('RFQ-T firm quotes require the presence of a taker address');
        }

        const firmQuoteResponses = await this._getQuotesAsync<RFQTFirmQuote>( // not yet BigNumber
            makerAssetData,
            takerAssetData,
            assetFillAmount,
            marketOperation,
            comparisonPrice,
            _opts,
            'firm',
        );

        const result: RFQTFirmQuote[] = [];
        firmQuoteResponses.forEach(firmQuoteResponse => {
            const orderWithStringInts = firmQuoteResponse.response.signedOrder;

            try {
                const hasValidSchema = this._schemaValidator.isValid(orderWithStringInts, schemas.signedOrderSchema);
                if (!hasValidSchema) {
                    throw new Error('Order not valid');
                }
            } catch (err) {
                this._warningLogger(orderWithStringInts, `Invalid RFQ-t order received, filtering out. ${err.message}`);
                return;
            }

            if (
                !hasExpectedAssetData(
                    makerAssetData,
                    takerAssetData,
                    orderWithStringInts.makerAssetData.toLowerCase(),
                    orderWithStringInts.takerAssetData.toLowerCase(),
                )
            ) {
                this._warningLogger(orderWithStringInts, 'Unexpected asset data in RFQ-T order, filtering out');
                return;
            }

            if (orderWithStringInts.takerAddress.toLowerCase() !== _opts.takerAddress.toLowerCase()) {
                this._warningLogger(orderWithStringInts, 'Unexpected takerAddress in RFQ-T order, filtering out');
                return;
            }

            const orderWithBigNumberInts: SignedOrder = {
                ...orderWithStringInts,
                makerAssetAmount: new BigNumber(orderWithStringInts.makerAssetAmount),
                takerAssetAmount: new BigNumber(orderWithStringInts.takerAssetAmount),
                makerFee: new BigNumber(orderWithStringInts.makerFee),
                takerFee: new BigNumber(orderWithStringInts.takerFee),
                expirationTimeSeconds: new BigNumber(orderWithStringInts.expirationTimeSeconds),
                salt: new BigNumber(orderWithStringInts.salt),
            };

            if (
                orderCalculationUtils.willOrderExpire(
                    orderWithBigNumberInts,
                    this._expiryBufferMs / constants.ONE_SECOND_MS,
                )
            ) {
                this._warningLogger(orderWithBigNumberInts, 'Expiry too soon in RFQ-T order, filtering out');
                return;
            }

            // Store makerUri for looking up later
            this._orderSignatureToMakerUri[orderWithBigNumberInts.signature] = firmQuoteResponse.makerUri;

            // Passed all validation, add it to result
            result.push({ signedOrder: orderWithBigNumberInts });
            return;
        });
        return result;
    }

    public async requestRfqtIndicativeQuotesAsync(
        makerAssetData: string,
        takerAssetData: string,
        assetFillAmount: BigNumber,
        marketOperation: MarketOperation,
        comparisonPrice: BigNumber | undefined,
        options: RfqtRequestOpts,
    ): Promise<RFQTIndicativeQuote[]> {
        const _opts: RfqtRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options };

        // Originally a takerAddress was required for indicative quotes, but
        // now we've eliminated that requirement.  @0x/quote-server, however,
        // is still coded to expect a takerAddress.  So if the client didn't
        // send one, just use the null address to satisfy the quote server's
        // expectations.
        if (!_opts.takerAddress) {
            _opts.takerAddress = constants.NULL_ADDRESS;
        }

        const responsesWithStringInts = await this._getQuotesAsync<RFQTIndicativeQuote>( // not yet BigNumber
            makerAssetData,
            takerAssetData,
            assetFillAmount,
            marketOperation,
            comparisonPrice,
            _opts,
            'indicative',
        );

        const validResponsesWithStringInts = responsesWithStringInts.filter(result => {
            const response = result.response;
            if (!this._isValidRfqtIndicativeQuoteResponse(response)) {
                this._warningLogger(response, 'Invalid RFQ-T indicative quote received, filtering out');
                return false;
            }
            if (
                !hasExpectedAssetData(makerAssetData, takerAssetData, response.makerAssetData, response.takerAssetData)
            ) {
                this._warningLogger(response, 'Unexpected asset data in RFQ-T indicative quote, filtering out');
                return false;
            }
            return true;
        });

        const validResponses = validResponsesWithStringInts.map(result => {
            const response = result.response;
            return {
                ...response,
                makerAssetAmount: new BigNumber(response.makerAssetAmount),
                takerAssetAmount: new BigNumber(response.takerAssetAmount),
                expirationTimeSeconds: new BigNumber(response.expirationTimeSeconds),
            };
        });

        const responses = validResponses.filter(response => {
            if (this._isExpirationTooSoon(response.expirationTimeSeconds)) {
                this._warningLogger(response, 'Expiry too soon in RFQ-T indicative quote, filtering out');
                return false;
            }
            return true;
        });

        return responses;
    }

    /**
     * Given an order signature, returns the makerUri that the order originated from
     */
    public getMakerUriForOrderSignature(orderSignature: string): string | undefined {
        return this._orderSignatureToMakerUri[orderSignature];
    }

    private _isValidRfqtIndicativeQuoteResponse(response: RFQTIndicativeQuote): boolean {
        const hasValidMakerAssetAmount =
            response.makerAssetAmount !== undefined &&
            this._schemaValidator.isValid(response.makerAssetAmount, schemas.wholeNumberSchema);
        const hasValidTakerAssetAmount =
            response.takerAssetAmount !== undefined &&
            this._schemaValidator.isValid(response.takerAssetAmount, schemas.wholeNumberSchema);
        const hasValidMakerAssetData =
            response.makerAssetData !== undefined &&
            this._schemaValidator.isValid(response.makerAssetData, schemas.hexSchema);
        const hasValidTakerAssetData =
            response.takerAssetData !== undefined &&
            this._schemaValidator.isValid(response.takerAssetData, schemas.hexSchema);
        const hasValidExpirationTimeSeconds =
            response.expirationTimeSeconds !== undefined &&
            this._schemaValidator.isValid(response.expirationTimeSeconds, schemas.wholeNumberSchema);
        if (
            hasValidMakerAssetAmount &&
            hasValidTakerAssetAmount &&
            hasValidMakerAssetData &&
            hasValidTakerAssetData &&
            hasValidExpirationTimeSeconds
        ) {
            return true;
        }
        return false;
    }

    private _makerSupportsPair(makerUrl: string, makerAssetData: string, takerAssetData: string): boolean {
        const makerTokenAddress = getTokenAddressOrThrow(makerAssetData);
        const takerTokenAddress = getTokenAddressOrThrow(takerAssetData);
        for (const assetPair of this._rfqtAssetOfferings[makerUrl]) {
            if (
                (assetPair[0] === makerTokenAddress && assetPair[1] === takerTokenAddress) ||
                (assetPair[0] === takerTokenAddress && assetPair[1] === makerTokenAddress)
            ) {
                return true;
            }
        }
        return false;
    }

    private _isExpirationTooSoon(expirationTimeSeconds: BigNumber): boolean {
        const expirationTimeMs = expirationTimeSeconds.times(constants.ONE_SECOND_MS);
        const currentTimeMs = new BigNumber(Date.now());
        return expirationTimeMs.isLessThan(currentTimeMs.plus(this._expiryBufferMs));
    }

    private async _getQuotesAsync<ResponseT>(
        makerAssetData: string,
        takerAssetData: string,
        assetFillAmount: BigNumber,
        marketOperation: MarketOperation,
        comparisonPrice: BigNumber | undefined,
        options: RfqtRequestOpts,
        quoteType: 'firm' | 'indicative',
    ): Promise<Array<{ response: ResponseT; makerUri: string }>> {
        const requestParams = QuoteRequestor.makeQueryParameters(
            options.takerAddress,
            marketOperation,
            makerAssetData,
            takerAssetData,
            assetFillAmount,
            comparisonPrice,
        );

        const result: Array<{ response: ResponseT; makerUri: string }> = [];
        await Promise.all(
            Object.keys(this._rfqtAssetOfferings).map(async url => {
                const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(url);
                const partialLogEntry = { url, quoteType, requestParams, isBlacklisted };
                if (isBlacklisted) {
                    this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry } });
                } else if (this._makerSupportsPair(url, makerAssetData, takerAssetData)) {
                    const timeBeforeAwait = Date.now();
                    const maxResponseTimeMs =
                        options.makerEndpointMaxResponseTimeMs === undefined
                            ? constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs!
                            : options.makerEndpointMaxResponseTimeMs;
                    try {
                        const quotePath = (() => {
                            switch (quoteType) {
                                case 'firm':
                                    return 'quote';
                                case 'indicative':
                                    return 'price';
                                default:
                                    throw new Error(`Unexpected quote type ${quoteType}`);
                            }
                        })();
                        const response = await quoteRequestorHttpClient.get<ResponseT>(`${url}/${quotePath}`, {
                            headers: { '0x-api-key': options.apiKey },
                            params: requestParams,
                            timeout: maxResponseTimeMs,
                        });
                        const latencyMs = Date.now() - timeBeforeAwait;
                        this._infoLogger({
                            rfqtMakerInteraction: {
                                ...partialLogEntry,
                                response: {
                                    included: true,
                                    apiKey: options.apiKey,
                                    takerAddress: requestParams.takerAddress,
                                    statusCode: response.status,
                                    latencyMs,
                                },
                            },
                        });
                        rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs >= maxResponseTimeMs);
                        result.push({ response: response.data, makerUri: url });
                    } catch (err) {
                        const latencyMs = Date.now() - timeBeforeAwait;
                        this._infoLogger({
                            rfqtMakerInteraction: {
                                ...partialLogEntry,
                                response: {
                                    included: false,
                                    apiKey: options.apiKey,
                                    takerAddress: requestParams.takerAddress,
                                    statusCode: err.response ? err.response.status : undefined,
                                    latencyMs,
                                },
                            },
                        });
                        rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs >= maxResponseTimeMs);
                        this._warningLogger(
                            convertIfAxiosError(err),
                            `Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${url} for API key ${
                                options.apiKey
                            } for taker address ${options.takerAddress}`,
                        );
                    }
                }
            }),
        );
        return result;
    }
}
