/**
 * @module botbuilder
 */
/**
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */
// tslint:disable-next-line:no-require-imports
import assert from 'assert';
import { v4 as uuidv4 } from 'uuid';
import {
    Activity,
    ActivityTypes,
    ConversationReference,
    ResourceResponse,
    TokenResponse,
    TokenExchangeRequest,
    SignInUrlResponse,
    ConversationAccount,
    ChannelAccount,
    Channels,
    RoleTypes,
    ActivityEx,
} from 'botframework-schema';
import { BotAdapter } from './botAdapter';
import { ExtendedUserTokenProvider } from './extendedUserTokenProvider';
import { TurnContext } from './turnContext';

/**
 * Signature for a function that can be used to inspect individual activities returned by a bot
 * that's being tested using the `TestAdapter`.
 *
 * ```TypeScript
 * type TestActivityInspector = (activity: Partial<Activity>, description: string) => void;
 * ```
 *
 * @param TestActivityInspector.activity The activity being inspected.
 * @param TestActivityInspector.description Text to log in the event of an error.
 */
export type TestActivityInspector = (activity: Partial<Activity>, description?: string) => void;

/**
 * Test adapter used for unit tests. This adapter can be used to simulate sending messages from the
 * user to the bot.
 *
 * @remarks
 * The following example sets up the test adapter and then executes a simple test:
 *
 * ```JavaScript
 * const { TestAdapter } = require('botbuilder');
 *
 * const adapter = new TestAdapter(async (context) => {
 *      await context.sendActivity(`Hello World`);
 * });
 *
 * adapter.test(`hi`, `Hello World`)
 *        .then(() => done());
 * ```
 */
export class TestAdapter extends BotAdapter implements ExtendedUserTokenProvider {
    /**
     * Creates a new TestAdapter instance.
     *
     * @param logicOrConversation The bots logic that's under test.
     * @param template (Optional) activity containing default values to assign to all test messages received.
     * @param sendTraceActivity Indicates whether the adapter should add to its queue any trace activities generated by the bot.
     */
    constructor(
        logicOrConversation?: ((context: TurnContext) => Promise<void>) | ConversationReference,
        template?: Partial<Activity>,
        sendTraceActivity = false,
    ) {
        super();
        this._sendTraceActivity = sendTraceActivity;
        this.template = template || {};
        if (logicOrConversation) {
            if (typeof logicOrConversation === 'function') {
                this._logic = logicOrConversation;
                this.conversation = TestAdapter.createConversation('Convo1');
            } else {
                this.conversation = logicOrConversation;
            }
        } else {
            this.conversation = TestAdapter.createConversation('Convo1');
        }

        Object.assign(this.conversation, {
            locale: this.template.locale || this.conversation.locale || this.locale,
            serviceUrl: this.template.serviceUrl || this.conversation.serviceUrl,
            channelId: this.template.channelId || this.conversation.channelId,
            bot: this.template.recipient || this.conversation.bot,
            user: this.template.from || this.conversation.user,
        });
    }

    /**
     * @private
     * INTERNAL: used to drive the promise chain forward when running tests.
     */
    get activityBuffer(): Partial<Activity>[] {
        return this.activeQueue;
    }

    /**
     * Gets a value indicating whether to send trace activities.
     *
     * @returns A value indicating whether to send trace activities.
     */
    get enableTrace(): boolean {
        return this._sendTraceActivity;
    }

    /**
     * Sets a value inidicating whether to send trace activities.
     */
    set enableTrace(value: boolean) {
        this._sendTraceActivity = value;
    }

    /**
     * Gets or sets the locale for the conversation.
     */
    locale = 'en-us';

    /**
     * Gets the queue of responses from the bot.
     */
    readonly activeQueue: Partial<Activity>[] = [];

    /**
     * Gets or sets a reference to the current conversation.
     */
    conversation: ConversationReference;

    /**
     * Create a ConversationReference.
     *
     * @param name name of the conversation (also id).
     * @param user name of the user (also id) default: User1.
     * @param bot name of the bot (also id) default: Bot.
     * @returns The [ConversationReference](xref:botframework-schema.ConversationReference).
     */
    static createConversation(name: string, user = 'User1', bot = 'Bot'): ConversationReference {
        const conversationReference: ConversationReference = {
            channelId: Channels.Test,
            serviceUrl: 'https://test.com',
            conversation: { isGroup: false, id: name, name: name } as ConversationAccount,
            user: { id: user.toLowerCase(), name: user } as ChannelAccount,
            bot: { id: bot.toLowerCase(), name: bot } as ChannelAccount,
            locale: 'en-us',
        };
        return conversationReference;
    }

    /**
     * Dequeues and returns the next bot response from the activeQueue.
     *
     * @returns The next activity in the queue; or undefined, if the queue is empty.
     */
    getNextReply(): Partial<Activity> {
        if (this.activeQueue.length > 0) {
            return this.activeQueue.shift();
        }
        return undefined;
    }

    /**
     * Creates a message activity from text and the current conversational context.
     *
     * @param text The message text.
     * @returns An appropriate message activity.
     */
    makeActivity(text?: string): Partial<Activity> {
        const activity: Partial<Activity> = {
            type: ActivityTypes.Message,
            locale: this.locale,
            from: this.conversation.user,
            recipient: this.conversation.bot,
            conversation: this.conversation.conversation,
            serviceUrl: this.conversation.serviceUrl,
            id: (this._nextId++).toString(),
            text: text,
        };
        return activity;
    }

    /**
     * Processes a message activity from a user.
     *
     * @param userSays The text of the user's message.
     * @param callback The bot logic to invoke.
     * @returns {Promise<any>} A promise representing the async operation.
     */
    sendTextToBot(userSays: string, callback: (context: TurnContext) => Promise<any>): Promise<any> {
        return this.processActivity(this.makeActivity(userSays), callback);
    }

    /**
     * `Activity` template that will be merged with all activities sent to the logic under test.
     */
    readonly template: Partial<Activity>;

    private _logic: (context: TurnContext) => Promise<void>;
    private _sendTraceActivity = false;
    private _nextId = 0;

    private readonly ExceptionExpected: string = 'ExceptionExpected';

    /**
     * Receives an activity and runs it through the middleware pipeline.
     *
     * @param activity The activity to process.
     * @param callback The bot logic to invoke.
     * @returns {Promise<any>} A promise representing the async operation.
     */
    async processActivity(
        activity: string | Partial<Activity>,
        callback?: (context: TurnContext) => Promise<any>,
    ): Promise<any> {
        const request: Partial<Activity> =
            typeof activity === 'string' ? { type: ActivityTypes.Message, text: activity } : activity;
        request.type = request.type || ActivityTypes.Message;
        request.channelId = request.channelId || this.conversation.channelId;

        if (!request.from || request.from.id === 'unknown' || request.from.role === RoleTypes.Bot) {
            request.from = this.conversation.user;
        }

        request.recipient = request.recipient || this.conversation.bot;
        request.conversation = request.conversation || this.conversation.conversation;
        request.serviceUrl = request.serviceUrl || this.conversation.serviceUrl;
        request.id = request.id || (this._nextId++).toString();
        request.timestamp = request.timestamp || new Date();

        Object.assign(request, this.template);
        const context = this.createContext(request);
        if (callback) {
            return await this.runMiddleware(context, callback);
        } else if (this._logic) {
            return await this.runMiddleware(context, this._logic);
        }
    }

    /**
     * @private
     * Sends activities to the conversation.
     * @param context Context object for the current turn of conversation with the user.
     * @param activities Set of activities sent by logic under test.
     */
    async sendActivities(context: TurnContext, activities: Partial<Activity>[]): Promise<ResourceResponse[]> {
        if (!context) {
            throw new Error('TurnContext cannot be null.');
        }

        if (!activities) {
            throw new Error('Activities cannot be null.');
        }

        if (activities.length == 0) {
            throw new Error('Expecting one or more activities, but the array was empty.');
        }

        const responses: ResourceResponse[] = [];

        for (let i = 0; i < activities.length; i++) {
            const activity = activities[i];

            if (!activity.id) {
                activity.id = uuidv4();
            }

            if (!activity.timestamp) {
                activity.timestamp = new Date();
            }

            if (activity.type === 'delay') {
                const delayMs = parseInt(activity.value);
                await new Promise((resolve) => setTimeout(resolve, delayMs));
            } else if (activity.type === ActivityTypes.Trace) {
                if (this._sendTraceActivity) {
                    this.activeQueue.push(activity);
                }
            } else {
                this.activeQueue.push(activity);
            }

            responses.push({ id: activity.id } as ResourceResponse);
        }

        return responses;
    }

    /**
     * @private
     * Replaces an existing activity in the activeQueue.
     * @param context Context object for the current turn of conversation with the user.
     * @param activity Activity being updated.
     * @returns promise representing async operation
     */
    updateActivity(context: TurnContext, activity: Partial<Activity>): Promise<ResourceResponse | void> {
        if (activity.id) {
            const idx = this.activeQueue.findIndex((a) => a.id === activity.id);
            if (idx !== -1) {
                this.activeQueue.splice(idx, 1, activity);
            }
            return Promise.resolve({ id: activity.id });
        }

        return Promise.resolve();
    }

    /**
     * @private
     * Deletes an existing activity in the activeQueue.
     * @param context Context object for the current turn of conversation with the user.
     * @param reference `ConversationReference` for activity being deleted.
     */
    deleteActivity(context: TurnContext, reference: Partial<ConversationReference>): Promise<void> {
        if (reference.activityId) {
            const idx = this.activeQueue.findIndex((a) => a.id === reference.activityId);
            if (idx !== -1) {
                this.activeQueue.splice(idx, 1);
            }
        }

        return Promise.resolve();
    }

    /**
     * @private
     * INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot.
     * This will cause the adapters middleware pipe to be run and it's logic to be called.
     * @param activity Text or activity from user. The current conversation reference [template](#template) will be merged the passed in activity to properly address the activity. Fields specified in the activity override fields in the template.
     */
    receiveActivity(activity: string | Partial<Activity>): Promise<void> {
        return this.processActivity(activity);
    }

    /**
     * The `TestAdapter` doesn't implement `continueConversation()` and will return an error if it's
     * called.
     *
     * @param _reference A reference to the conversation to continue.
     * @param _logic The asynchronous method to call after the adapter middleware runs.
     * @returns {Promise<void>} A promise representing the async operation.
     */
    continueConversation(
        _reference: Partial<ConversationReference>,
        _logic: (revocableContext: TurnContext) => Promise<void>,
    ): Promise<void> {
        return Promise.reject(new Error('not implemented'));
    }

    /**
     * Creates a turn context.
     *
     * @param request An incoming request body.
     * @returns The created [TurnContext](xref:botbuilder-core.TurnContext).
     * @remarks
     * Override this in a derived class to modify how the adapter creates a turn context.
     */
    protected createContext(request: Partial<Activity>): TurnContext {
        return new TurnContext(this, request);
    }

    /**
     * Sends something to the bot. This returns a new `TestFlow` instance which can be used to add
     * additional steps for inspecting the bots reply and then sending additional activities.
     *
     * @remarks
     * This example shows how to send a message and then verify that the response was as expected:
     *
     * ```JavaScript
     * adapter.send('hi')
     *        .assertReply('Hello World')
     *        .then(() => done());
     * ```
     * @param userSays Text or activity simulating user input.
     * @returns a new [TestFlow](xref:botbuilder-core.TestFlow) instance which can be used to add additional steps
     * for inspecting the bots reply and then sending additional activities.
     */
    send(userSays: string | Partial<Activity>): TestFlow {
        return new TestFlow(this.processActivity(userSays), this);
    }

    /**
     * Send something to the bot and expects the bot to return with a given reply.
     *
     * @remarks
     * This is simply a wrapper around calls to `send()` and `assertReply()`. This is such a
     * common pattern that a helper is provided.
     *
     * ```JavaScript
     * adapter.test('hi', 'Hello World')
     *        .then(() => done());
     * ```
     * @param userSays Text or activity simulating user input.
     * @param expected Expected text or activity of the reply sent by the bot.
     * @param description (Optional) Description of the test case. If not provided one will be generated.
     * @param _timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    test(
        userSays: string | Partial<Activity>,
        expected: string | Partial<Activity> | ((activity: Partial<Activity>, description?: string) => void),
        description?: string,
        _timeout?: number,
    ): TestFlow {
        return this.send(userSays).assertReply(expected, description);
    }

    /**
     * Test a list of activities.
     *
     * @remarks
     * Each activity with the "bot" role will be processed with assertReply() and every other
     * activity will be processed as a user message with send().
     * @param activities Array of activities.
     * @param description (Optional) Description of the test case. If not provided one will be generated.
     * @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    testActivities(activities: Partial<Activity>[], description?: string, timeout?: number): TestFlow {
        if (!activities) {
            throw new Error('Missing array of activities');
        }

        const activityInspector: any =
            (expected: Partial<Activity>): TestActivityInspector =>
            (actual: Partial<Activity>, description2: string): any =>
                validateTranscriptActivity(actual, expected, description2);

        // Chain all activities in a TestFlow, check if its a user message (send) or a bot reply (assert)
        return activities.reduce(
            (flow: TestFlow, activity: Partial<Activity>) => {
                // tslint:disable-next-line:prefer-template
                const assertDescription = `reply ${description ? ' from ' + description : ''}`;

                return this.isReply(activity)
                    ? flow.assertReply(activityInspector(activity, description), assertDescription, timeout)
                    : flow.send(activity);
            },
            new TestFlow(Promise.resolve(), this),
        );
    }

    private _userTokens: UserToken[] = [];
    private _magicCodes: TokenMagicCode[] = [];

    /**
     * Adds a fake user token so it can later be retrieved.
     *
     * @param connectionName The connection name.
     * @param channelId The channel id.
     * @param userId The user id.
     * @param token The token to store.
     * @param magicCode (Optional) The optional magic code to associate with this token.
     */
    addUserToken(connectionName: string, channelId: string, userId: string, token: string, magicCode?: string) {
        const key: UserToken = new UserToken();
        key.channelId = channelId;
        key.connectionName = connectionName;
        key.userId = userId;
        key.token = token;

        if (!magicCode) {
            this._userTokens.push(key);
        } else {
            const mc = new TokenMagicCode();
            mc.key = key;
            mc.magicCode = magicCode;
            this._magicCodes.push(mc);
        }
    }

    /**
     * Asynchronously retrieves the token status for each configured connection for the given user.
     * In testAdapter, retrieves tokens which were previously added via addUserToken.
     *
     * @param context The context object for the turn.
     * @param userId The ID of the user to retrieve the token status for.
     * @param includeFilter Optional. A comma-separated list of connection's to include. If present,
     *      the `includeFilter` parameter limits the tokens this method returns.
     * @param _oAuthAppCredentials AppCredentials for OAuth.
     * @returns The [TokenStatus](xref:botframework-connector.TokenStatus) objects retrieved.
     */
    async getTokenStatus(
        context: TurnContext,
        userId: string,
        includeFilter?: string,
        _oAuthAppCredentials?: any,
    ): Promise<any[]> {
        if (!context || !context.activity) {
            throw new Error('testAdapter.getTokenStatus(): context with activity is required');
        }

        if (!userId && (!context.activity.from || !context.activity.from.id)) {
            throw new Error('testAdapter.getTokenStatus(): missing userId, from or from.id');
        }

        const filter = includeFilter ? includeFilter.split(',') : undefined;
        if (!userId) {
            userId = context.activity.from.id;
        }

        return this._userTokens
            .filter(
                (x) =>
                    x.channelId === context.activity.channelId &&
                    x.userId === userId &&
                    (!filter || filter.includes(x.connectionName)),
            )
            .map((token) => ({
                ConnectionName: token.connectionName,
                HasToken: true,
                ServiceProviderDisplayName: token.connectionName,
            }));
    }

    /**
     * Retrieves the OAuth token for a user that is in a sign-in flow.
     *
     * @param context Context for the current turn of conversation with the user.
     * @param connectionName Name of the auth connection to use.
     * @param magicCode (Optional) Optional user entered code to validate.
     * @returns The OAuth token for a user that is in a sign-in flow.
     */
    async getUserToken(context: TurnContext, connectionName: string, magicCode?: string): Promise<TokenResponse> {
        const key: UserToken = new UserToken();
        key.channelId = context.activity.channelId;
        key.connectionName = connectionName;
        key.userId = context.activity.from.id;

        if (magicCode) {
            const magicCodeRecord = this._magicCodes.find(
                (item) => key.equalsKey(item.key) && item.magicCode === magicCode,
            );
            if (magicCodeRecord) {
                // move the token to long term dictionary
                this.addUserToken(connectionName, key.channelId, key.userId, magicCodeRecord.key.token);

                // remove from the magic code list
                const idx = this._magicCodes.indexOf(magicCodeRecord);
                this._magicCodes.splice(idx, 1);
            }
        }

        const userToken = this._userTokens.find((token) => key.equalsKey(token));
        return userToken && Object.assign({ expiration: undefined }, userToken);
    }

    /**
     * Signs the user out with the token server.
     *
     * @param context Context for the current turn of conversation with the user.
     * @param connectionName Name of the auth connection to use.
     * @param userId User ID to sign out.
     */
    async signOutUser(context: TurnContext, connectionName?: string, userId?: string): Promise<void> {
        const channelId = context.activity.channelId;
        userId = userId || context.activity.from.id;
        this._userTokens = this._userTokens.filter(
            (token) =>
                connectionName &&
                (connectionName !== token.connectionName || channelId !== token.channelId || userId !== token.userId),
        );
    }

    /**
     * Gets a signin link from the token server that can be sent as part of a SigninCard.
     *
     * @param context Context for the current turn of conversation with the user.
     * @param connectionName Name of the auth connection to use.
     * @returns A signin link from the token server that can be sent as part of a SigninCard.
     */
    async getSignInLink(context: TurnContext, connectionName: string): Promise<string> {
        return `https://fake.com/oauthsignin/${connectionName}/${context.activity.channelId}/${context.activity.from.id}`;
    }

    /**
     * Signs the user out with the token server.
     *
     * @param _context Context for the current turn of conversation with the user.
     * @param _connectionName Name of the auth connection to use.
     * @param _resourceUrls The list of resource URLs to retrieve tokens for.
     * @returns A Dictionary of resourceUrl to the corresponding TokenResponse.
     */
    async getAadTokens(
        _context: TurnContext,
        _connectionName: string,
        _resourceUrls: string[],
    ): Promise<{
        [propertyName: string]: TokenResponse;
    }> {
        return undefined;
    }

    private exchangeableTokens: { [key: string]: ExchangeableToken } = {};

    /**
     * Adds a fake exchangeable token so it can be exchanged later.
     *
     * @param connectionName Name of the auth connection to use.
     * @param channelId Channel ID.
     * @param userId User ID.
     * @param exchangeableItem Exchangeable token or resource URI.
     * @param token Token to store.
     */
    addExchangeableToken(
        connectionName: string,
        channelId: string,
        userId: string,
        exchangeableItem: string,
        token: string,
    ) {
        const key: ExchangeableToken = new ExchangeableToken();
        key.channelId = channelId;
        key.connectionName = connectionName;
        key.userId = userId;
        key.exchangeableItem = exchangeableItem;
        key.token = token;
        this.exchangeableTokens[key.toKey()] = key;
    }

    /**
     * Gets a sign-in resource.
     *
     * @param context [TurnContext](xref:botbuilder-core.TurnContext) for the current turn of conversation with the user.
     * @param connectionName Name of the auth connection to use.
     * @param userId User ID
     * @param _finalRedirect Final redirect URL.
     * @returns A `Promise` with a new [SignInUrlResponse](xref:botframework-schema.SignInUrlResponse) object.
     */
    async getSignInResource(
        context: TurnContext,
        connectionName: string,
        userId?: string,
        _finalRedirect?: string,
    ): Promise<SignInUrlResponse> {
        return {
            signInLink: `https://botframeworktestadapter.com/oauthsignin/${connectionName}/${context.activity.channelId}/${userId}`,
            tokenExchangeResource: {
                id: String(Math.random()),
                providerId: null,
                uri: `api://${connectionName}/resource`,
            },
        };
    }

    /**
     * Performs a token exchange operation such as for single sign-on.
     *
     * @param context [TurnContext](xref:botbuilder-core.TurnContext) for the current turn of conversation with the user.
     * @param connectionName Name of the auth connection to use.
     * @param userId User id associated with the token.
     * @param tokenExchangeRequest Exchange request details, either a token to exchange or a uri to exchange.
     * @returns If the promise completes, the exchanged token is returned.
     */
    async exchangeToken(
        context: TurnContext,
        connectionName: string,
        userId: string,
        tokenExchangeRequest: TokenExchangeRequest,
    ): Promise<TokenResponse> {
        const exchangeableValue: string = tokenExchangeRequest.token
            ? tokenExchangeRequest.token
            : tokenExchangeRequest.uri;
        const key = new ExchangeableToken();
        key.channelId = context.activity.channelId;
        key.connectionName = connectionName;
        key.exchangeableItem = exchangeableValue;
        key.userId = userId;

        const tokenExchangeResponse = this.exchangeableTokens[key.toKey()];
        if (tokenExchangeResponse && tokenExchangeResponse.token === this.ExceptionExpected) {
            throw new Error('Exception occurred during exchanging tokens');
        }

        return tokenExchangeResponse
            ? {
                  channelId: key.channelId,
                  connectionName: key.connectionName,
                  token: tokenExchangeResponse.token,
                  expiration: null,
              }
            : null;
    }

    /**
     * Adds an instruction to throw an exception during exchange requests.
     *
     * @param connectionName The connection name.
     * @param channelId The channel id.
     * @param userId The user id.
     * @param exchangeableItem The exchangeable token or resource URI.
     */
    throwOnExchangeRequest(connectionName: string, channelId: string, userId: string, exchangeableItem: string): void {
        const token: ExchangeableToken = new ExchangeableToken();
        token.channelId = channelId;
        token.connectionName = connectionName;
        token.userId = userId;
        token.exchangeableItem = exchangeableItem;
        const key = token.toKey();

        token.token = this.ExceptionExpected;
        this.exchangeableTokens[key] = token;
    }

    /**
     * Indicates if the activity is a reply from the bot (role == 'bot')
     *
     * @remarks
     * Checks to see if the from property and if from.role exists on the Activity before
     * checking to see who the activity is from. Otherwise returns false by default.
     * @param activity Activity to check.
     * @returns True if the activity is a reply from the bot, otherwise, false.
     */
    private isReply(activity: Partial<Activity>): boolean {
        if (activity.from && activity.from.role) {
            return activity.from.role && activity.from.role.toLocaleLowerCase() === 'bot';
        } else {
            return false;
        }
    }
}

class UserToken {
    connectionName: string;
    userId: string;
    channelId: string;
    token: string;

    equalsKey(rhs: UserToken): boolean {
        return (
            rhs &&
            this.connectionName === rhs.connectionName &&
            this.userId === rhs.userId &&
            this.channelId === rhs.channelId
        );
    }
}

class TokenMagicCode {
    key: UserToken;
    magicCode: string;
}

class ExchangeableToken extends UserToken {
    exchangeableItem: string;

    equalsKey(rhs: ExchangeableToken): boolean {
        return rhs != null && this.exchangeableItem === rhs.exchangeableItem && super.equalsKey(rhs);
    }

    toKey(): string {
        return this.exchangeableItem;
    }
}

/**
 * Support class for `TestAdapter` that allows for the simple construction of a sequence of tests.
 *
 * @remarks
 * Calling `adapter.send()` or `adapter.test()` will create a new test flow which you can chain
 * together additional tests using a fluent syntax.
 *
 * ```JavaScript
 * const { TestAdapter } = require('botbuilder');
 *
 * const adapter = new TestAdapter(async (context) => {
 *    if (context.text === 'hi') {
 *       await context.sendActivity(`Hello World`);
 *    } else if (context.text === 'bye') {
 *       await context.sendActivity(`Goodbye`);
 *    }
 * });
 *
 * adapter.test(`hi`, `Hello World`)
 *        .test(`bye`, `Goodbye`)
 *        .then(() => done());
 * ```
 */
export class TestFlow {
    /**
     * @private
     * INTERNAL: creates a new TestFlow instance.
     * @param previous Promise chain for the current test sequence.
     * @param adapter Adapter under tested.
     * @param callback The bot turn processing logic to test.
     */
    constructor(
        public previous: Promise<void>,
        private adapter: TestAdapter,
        private callback?: (turnContext: TurnContext) => Promise<unknown>,
    ) {}

    /**
     * Send something to the bot and expects the bot to return with a given reply. This is simply a
     * wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a
     * helper is provided.
     *
     * @param userSays Text or activity simulating user input.
     * @param expected Expected text or activity of the reply sent by the bot.
     * @param description (Optional) Description of the test case. If not provided one will be generated.
     * @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    test(
        userSays: string | Partial<Activity>,
        expected: string | Partial<Activity> | ((activity: Partial<Activity>, description?: string) => void),
        description?: string,
        timeout?: number,
    ): TestFlow {
        return this.send(userSays).assertReply(expected, description || `test("${userSays}", "${expected}")`, timeout);
    }

    /**
     * Sends something to the bot.
     *
     * @param userSays Text or activity simulating user input.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    send(userSays: string | Partial<Activity>): TestFlow {
        return new TestFlow(
            this.previous.then(() => this.adapter.processActivity(userSays, this.callback)),
            this.adapter,
            this.callback,
        );
    }

    /**
     * Creates a conversation update activity and process the activity.
     *
     * @returns {TestFlow} A new TestFlow object.
     */
    sendConversationUpdate(): TestFlow {
        return new TestFlow(
            this.previous.then(() => {
                const cu = ActivityEx.createConversationUpdateActivity();
                cu.membersAdded ??= [];
                cu.membersAdded.push(this.adapter.conversation.user);
                return this.adapter.processActivity(cu, this.callback);
            }),
            this.adapter,
            this.callback,
        );
    }

    /**
     * Generates an assertion if the bots response doesn't match the expected text/activity.
     *
     * @param expected Expected text or activity from the bot. Can be a callback to inspect the response using custom logic.
     * @param description (Optional) Description of the test case. If not provided one will be generated.
     * @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    assertReply(
        expected: string | Partial<Activity> | TestActivityInspector,
        description?: string,
        timeout?: number,
    ): TestFlow {
        function defaultInspector(reply: Partial<Activity>, description2?: string): void {
            if (typeof expected === 'object') {
                validateActivity(reply, expected);
            } else {
                assert.equal(reply.type, ActivityTypes.Message, `${description2} type === '${reply.type}'. `);
                assert.equal(reply.text, expected, `${description2} text === "${reply.text}"`);
            }
        }

        if (!description) {
            description = '';
        }
        const inspector: TestActivityInspector = typeof expected === 'function' ? expected : defaultInspector;

        return new TestFlow(
            this.previous.then(() => {
                // tslint:disable-next-line:promise-must-complete
                return new Promise<void>((resolve: any, reject: any): void => {
                    if (!timeout) {
                        timeout = 3000;
                    }
                    const start: number = new Date().getTime();
                    const adapter: TestAdapter = this.adapter;

                    function waitForActivity(): void {
                        const current: number = new Date().getTime();
                        if (current - start > <number>timeout) {
                            // Operation timed out
                            let expecting: string;
                            switch (typeof expected) {
                                case 'string':
                                default:
                                    expecting = `"${expected.toString()}"`;
                                    break;
                                case 'object':
                                    expecting = `"${(expected as Activity).text}`;
                                    break;
                                case 'function':
                                    expecting = expected.toString();
                                    break;
                            }
                            reject(
                                new Error(
                                    `TestAdapter.assertReply(${expecting}): ${description} Timed out after ${
                                        current - start
                                    }ms.`,
                                ),
                            );
                        } else if (adapter.activeQueue.length > 0) {
                            // Activity received
                            const reply: Partial<Activity> = adapter.activeQueue.shift() as Activity;
                            try {
                                inspector(reply, description as string);
                            } catch (err) {
                                reject(err);
                            }
                            resolve();
                        } else {
                            setTimeout(waitForActivity, 5);
                        }
                    }
                    waitForActivity();
                });
            }),
            this.adapter,
            this.callback,
        );
    }

    /**
     * Generates an assertion that the turn processing logic did not generate a reply from the bot, as expected.
     *
     * @param description (Optional) Description of the test case. If not provided one will be generated.
     * @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    assertNoReply(description?: string, timeout?: number): TestFlow {
        return new TestFlow(
            this.previous.then(() => {
                // tslint:disable-next-line:promise-must-complete
                return new Promise<void>((resolve: any): void => {
                    if (!timeout) {
                        timeout = 3000;
                    }
                    const start: number = new Date().getTime();
                    const adapter: TestAdapter = this.adapter;

                    function waitForActivity(): void {
                        const current: number = new Date().getTime();
                        if (current - start > <number>timeout) {
                            // Operation timed out and received no reply
                            resolve();
                        } else if (adapter.activeQueue.length > 0) {
                            // Activity received
                            const reply: Partial<Activity> = adapter.activeQueue.shift() as Activity;
                            assert.strictEqual(
                                reply,
                                undefined,
                                `${JSON.stringify(reply)} is responded when waiting for no reply: '${description}'`,
                            );
                            resolve();
                        } else {
                            setTimeout(waitForActivity, 5);
                        }
                    }
                    waitForActivity();
                });
            }),
            this.adapter,
            this.callback,
        );
    }

    /**
     * Generates an assertion if the bots response is not one of the candidate strings.
     *
     * @param candidates List of candidate responses.
     * @param description (Optional) Description of the test case. If not provided one will be generated.
     * @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    assertReplyOneOf(candidates: string[], description?: string, timeout?: number): TestFlow {
        return this.assertReply(
            (activity: Partial<Activity>, description2: string) => {
                for (const candidate of candidates) {
                    if (activity.text === candidate) {
                        return;
                    }
                }
                assert.fail(
                    `TestAdapter.assertReplyOneOf(): ${description2 || ''} FAILED, Expected one of :${JSON.stringify(
                        candidates,
                    )}`,
                );
            },
            description,
            timeout,
        );
    }

    /**
     * Inserts a delay before continuing.
     *
     * @param ms ms to wait.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    delay(ms: number): TestFlow {
        return new TestFlow(
            this.previous.then(() => {
                return new Promise<void>((resolve: any, _reject: any): void => {
                    setTimeout(resolve, ms);
                });
            }),
            this.adapter,
            this.callback,
        );
    }

    /**
     * Adds a `then()` step to the tests promise chain.
     *
     * @param onFulfilled Code to run if the test is currently passing.
     * @param onRejected Code to run if the test has thrown an error.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    then(onFulfilled?: () => void, onRejected?: (err) => void): TestFlow {
        return new TestFlow(this.previous.then(onFulfilled, onRejected), this.adapter, this.callback);
    }

    /**
     * Adds a finally clause. Note that you can't keep chaining afterwards.
     *
     * @param onFinally Code to run after the test chain.
     * @returns {Promise<void>} A promise representing the async operation.
     */
    finally(onFinally: () => void): Promise<void> {
        return Promise.resolve(this.previous.finally(onFinally));
    }

    /**
     * Adds a `catch()` clause to the tests promise chain.
     *
     * @param onRejected Code to run if the test has thrown an error.
     * @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
     */
    catch(onRejected?: (reason: any) => void): TestFlow {
        return new TestFlow(this.previous.catch(onRejected), this.adapter, this.callback);
    }

    /**
     * Start the test sequence, returning a promise to await.
     *
     * @returns {Promise<void>} A promise representing the async operation.
     */
    startTest(): Promise<void> {
        return this.previous;
    }
}

/**
 * @private
 * @param activity an activity object to validate
 * @param expected expected object to validate against
 */
function validateActivity(activity: Partial<Activity>, expected: Partial<Activity>): void {
    // tslint:disable-next-line:forin
    Object.keys(expected).forEach((prop: any) => {
        assert.equal((<any>activity)[prop], (<any>expected)[prop]);
    });
}

/**
 * @private
 * Does a shallow comparison of:
 * - type
 * - text
 * - speak
 * - suggestedActions
 */
function validateTranscriptActivity(
    activity: Partial<Activity>,
    expected: Partial<Activity>,
    description: string,
): void {
    assert.equal(activity.type, expected.type, `failed "type" assert on ${description}`);
    assert.equal(activity.text, expected.text, `failed "text" assert on ${description}`);
    assert.equal(activity.speak, expected.speak, `failed "speak" assert on ${description}`);
    assert.deepEqual(
        activity.suggestedActions,
        expected.suggestedActions,
        `failed "suggestedActions" assert on ${description}`,
    );
}
