/**
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
/**
 * @fileoverview Implementation of test handler around the Actions API, used to
 * setup and conveniently create tests.
 */
/* eslint-disable  @typescript-eslint/no-explicit-any */
import {protos} from '@assistant/actions';
import {assert, expect} from 'chai';
import * as fs from 'fs';
import * as i18n from 'i18n';
import * as yaml from 'js-yaml';

import {ActionsApiHelper} from './actions-api-helper';
import * as constants from './constants';
import {getDeepMerge} from './merge';

const CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE =
  'cannot be called before first query';

i18n.configure({
  locales: constants.SUPPORTED_LOCALES,
  fallbacks: constants.FALLBACK_LOCALES,
  directory: __dirname + '/locales',
  defaultLocale: constants.DEFAULT_LOCALE,
});

/** Map that controls the assert's comparing mode. */
interface AssertValueArgs {
  /** Whether an exact match is expected. Default is false. */
  isExact?: boolean;
  /** Whether to do a regexp match. Default is false. */
  isRegexp?: boolean;
}

/** Format of MatchIntent 'suite' of test cases. */
interface MatchIntentsTestSuite {
  /** Optional. the locale of the tested queries. */
  defaultLanguage?: string;
  /** The match intent test cases. */
  testCases?: MatchIntentsTestCase[];
}

/** A MatchIntent test case. */
interface MatchIntentsTestCase {
  /** the checked query. */
  query: string;
  /** the expected top matched intent. */
  expectedIntent: string;
}

/** Test suite configuration interface. */
export interface TestSuiteConfig {
  /** the tested project ID. */
  projectId: string;
  /** optional override of the suite default interaction params. */
  interactionParams?: protos.google.actions.sdk.v2.ISendInteractionRequest;
  /** optional custom actions API endpoint. */
  actionsApiCustomEndpoint?: string;
}

/**
 * A class implementing a testing framework wrapping manager class.
 */
export class ActionsOnGoogleTestManager {
  actionsApiHelper: ActionsApiHelper;
  latestResponse: protos.google.actions.sdk.v2.ISendInteractionResponse | null = null;
  suiteInteractionDefaults: protos.google.actions.sdk.v2.ISendInteractionRequest =
    constants.DEFAULT_INTERACTION_SETTING;
  testInteractionDefaults: protos.google.actions.sdk.v2.ISendInteractionRequest = {};
  lastUserQuery: string | null | undefined = null;

  /**
   * Sets up all the needed objects and settings of a Suite.
   */
  constructor({
    projectId,
    interactionParams = {},
    actionsApiCustomEndpoint,
  }: TestSuiteConfig) {
    this.updateSuiteInteractionDefaults(interactionParams);
    this.cleanUpAfterTest();
    this.actionsApiHelper = new ActionsApiHelper({
      projectId,
      actionsApiCustomEndpoint,
    });
  }

  /**
   * Cleans up the test scenario temporary artifacts. Should run after each
   * test scenario.
   */
  cleanUpAfterTest() {
    this.lastUserQuery = null;
    this.latestResponse = null;
    this.testInteractionDefaults = {};
  }

  /** Send a query to your action */
  sendQuery(
    queryText: string
  ): Promise<protos.google.actions.sdk.v2.ISendInteractionResponse> {
    console.info(`--- sendQuery called with '${queryText}'`);
    return this.sendInteraction({input: {query: queryText}});
  }

  /** Send an interaction object to your action  */
  async sendInteraction(
    interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest
  ): Promise<protos.google.actions.sdk.v2.ISendInteractionResponse> {
    const interactionMergeParams = getDeepMerge(
      this.getTestInteractionMergedDefaults(),
      interactionParams
    );
    // Set the conversation token - if not the first query
    if (this.latestResponse) {
      assert.isFalse(
        this.getIsConversationEnded(),
        'Conversation ended unexpectedly in previous query.'
      );
      interactionMergeParams[constants.TOKEN_FIELD_NAME] = this.latestResponse[
        constants.TOKEN_FIELD_NAME
      ];
    }
    this.lastUserQuery = interactionMergeParams.input!['query'];
    this.latestResponse = await this.actionsApiHelper.sendInteraction(
      interactionMergeParams
    );
    this.validateSendInteractionResponse(this.latestResponse);
    return this.latestResponse!;
  }

  /** Send a 'stop' query, to stop/exit the action. */
  sendStop() {
    return this.sendQuery(this.getStopQuery());
  }

  /** Calls the 'writePreview' API method from draft. */
  async writePreviewFromDraft() {
    console.info('Starting writePreview From Draft');
    await this.actionsApiHelper.writePreviewFromDraft();
    console.info('writePreview From Draft completed');
  }

  /** Calls the 'writePreview' API method from submitted version number. */
  async writePreviewFromVersion(versionNumber: number) {
    console.info(`Starting writePreview From Version ${versionNumber}`);
    await this.actionsApiHelper.writePreviewFromVersion(versionNumber);
    console.info('writePreview From Version completed');
  }

  // -------------- Update/Set query params
  /** Overrides the suite interaction defaults. */
  setSuiteInteractionDefaults(
    interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest
  ) {
    this.suiteInteractionDefaults = interactionParams;
  }

  /** Updates the suite interaction defaults. */
  updateSuiteInteractionDefaults(
    interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest
  ) {
    this.suiteInteractionDefaults = getDeepMerge(
      this.suiteInteractionDefaults,
      interactionParams
    );
  }

  // Update/Set query params
  /** Sets the default locale for the suite. */
  setSuiteLocale(locale: string) {
    this.updateSuiteInteractionDefaults({deviceProperties: {locale}});
    this.updateCurrentLocale(locale);
  }

  /** Sets the default surface for the suite. */
  setSuiteSurface(surface: string) {
    const devicePropertiesSurface = surface as keyof typeof protos.google.actions.sdk.v2.DeviceProperties.Surface;
    this.updateSuiteInteractionDefaults({
      deviceProperties: {surface: devicePropertiesSurface},
    });
  }

  // Update/Set query params
  /**
   * Sets the default locale for the current test scenario. Only needed for
   * tests that are for different locales from the suite locale.
   */
  setTestLocale(locale: string) {
    this.updateTestInteractionDefaults({deviceProperties: {locale}});
    this.updateCurrentLocale(locale);
  }

  /**
   * Sets the default surface for the current test scenario. Only needed for
   * tests that are for different surface from the suite surface.
   */
  setTestSurface(surface: string) {
    const devicePropertiesSurface = surface as keyof typeof protos.google.actions.sdk.v2.DeviceProperties.Surface;
    this.updateTestInteractionDefaults({
      deviceProperties: {surface: devicePropertiesSurface},
    });
  }

  /** Overrides the test scenario interaction defaults. */
  setTestInteractionDefaults(
    interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest
  ) {
    this.testInteractionDefaults = interactionParams;
  }

  /** Updates the test scenario interaction defaults. */
  updateTestInteractionDefaults(
    interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest
  ) {
    this.testInteractionDefaults = getDeepMerge(
      this.testInteractionDefaults,
      interactionParams
    );
  }

  /** Returns the test scenario interaction defaults. */
  getTestInteractionMergedDefaults(): protos.google.actions.sdk.v2.ISendInteractionRequest {
    return getDeepMerge(
      this.suiteInteractionDefaults,
      this.testInteractionDefaults
    );
  }

  // --------------- Asserts From Response:
  /**
   * Asserts the response Speech (concatenation of the first_simple and
   * last_simple Speech)
   */
  assertSpeech(
    expected: string | string[],
    assertParams: AssertValueArgs = {},
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertSpeech ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const speech = this.getSpeech(checkedResponse!);
    assert.isDefined(
      speech,
      'Speech field is missing from the last response: ' +
        JSON.stringify(speech)
    );
    this.assertValueCommon(speech!, expected, 'speech', assertParams);
  }

  /**
   * Asserts the response Text (concatenation of the first_simple and
   * last_simple Text)
   */
  assertText(
    expected: string | string[],
    assertParams: AssertValueArgs = {},
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertText ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;

    const text = this.getText(checkedResponse!);
    assert.isDefined(
      text,
      'Text field is missing from the last response: ' + JSON.stringify(text)
    );
    this.assertValueCommon(text!, expected, 'text', assertParams);
  }

  /** Asserts the response's Intent. */
  assertIntent(
    expected: string,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertIntent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const intentName = this.getIntent(checkedResponse!);
    assert.equal(intentName, expected, 'Unexpected intent.');
  }

  /** Asserts a response's Intent Parameter value. */
  assertIntentParameter(
    parameterName: string,
    expected: any,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertIntentParameter ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const parameterValue = this.getIntentParameter(
      parameterName,
      checkedResponse!
    );
    assert.exists(
      parameterValue,
      `Intent parameter ${parameterValue} has no value.`
    );
    assert.deepEqual(
      parameterValue,
      expected,
      'Unexpected intent parameter value.'
    );
  }

  /** Asserts the response's Last Scene. */
  assertScene(
    expected: string,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertScene ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const sceneName = this.getScene(checkedResponse!);
    assert.equal(sceneName, expected);
  }

  /** Asserts the prompt response. */
  assertPrompt(
    expected: protos.google.actions.sdk.v2.conversation.IPrompt,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertPrompt ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const prompt = this.getPrompt(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(prompt, expected);
    } else {
      assert.deepOwnInclude(prompt, expected);
    }
  }

  /** Asserts the Suggestion Chips. */
  assertSuggestions(
    expected: protos.google.actions.sdk.v2.conversation.ISuggestion[],
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertSuggestions ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const suggestions = this.getSuggestions(checkedResponse!);
    assert.exists(suggestions);
    assert.equal(suggestions!.length, expected.length);
    // Note: since deepEqual and deepOwnInclude are not working on Arrays, so
    // we need to compare each element separately.
    for (let i = 0; i < suggestions!.length; ++i) {
      if (requireExact) {
        assert.deepEqual(suggestions![i], expected[i]);
      } else {
        assert.deepOwnInclude(suggestions![i], expected[i]);
      }
    }
  }

  /** Asserts the Canvas URL. */
  assertCanvasURL(
    expected: string | undefined | null,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertCanvasURL ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const canvasURL = this.getCanvasURL(checkedResponse!);
    assert.equal(canvasURL, expected);
  }

  /** Asserts the Canvas Data. */
  assertCanvasData(
    expected: any[],
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertCanvasData ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const canvasData = this.getCanvasData(checkedResponse!);
    assert.exists(canvasData);
    assert.equal(canvasData!.length, expected.length);
    // Note: since deepEqual and deepOwnInclude are not working on Arrays, so
    // we need to compare each element separately.
    for (let i = 0; i < canvasData!.length; ++i) {
      if (requireExact) {
        assert.deepEqual(canvasData![i], expected[i]);
      } else {
        assert.deepOwnInclude(canvasData![i], expected[i]);
      }
    }
  }

  /** Asserts that the conversation ended, based on the response. */
  assertConversationEnded(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertConversationEnded ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    assert.isTrue(
      this.getIsConversationEnded(checkedResponse!),
      'Failed since Conversation is not completed as expected.'
    );
  }

  /** Asserts that the conversation did not end, based on the response. */
  assertConversationNotEnded(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertConversationNotEnded ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    assert.isFalse(
      this.getIsConversationEnded(checkedResponse!),
      'Failed since Conversation has completed too early.'
    );
  }

  /**
   * Asserts the session storage parameter value, in the given response.
   */
  assertSessionParam(
    name: string,
    expected: any,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertSessionParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const value = this.getSessionParam(name, checkedResponse!);
    assert.deepEqual(
      value,
      expected,
      'Unexpected SessionParam variable ' + name
    );
  }

  /**
   * Asserts the user storage parameter value, in the given response.
   */
  assertUserParam(
    name: string,
    expected: any,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertUserParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const value = this.getUserParam(name, checkedResponse!);
    assert.deepEqual(value, expected, 'Unexpected UserParam variable ' + name);
  }

  /**
   * Asserts the home storage parameter value, in the given response.
   */
  assertHomeParam(
    name: string,
    expected: any,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertHomeParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const value = this.getHomeParam(name, checkedResponse!);
    assert.deepEqual(value, expected, 'Unexpected HomeParam variable ' + name);
  }

  /** Asserts the Card response. */
  assertCard(
    expected: protos.google.actions.sdk.v2.conversation.ICard,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertCard ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const card = this.getCard(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(card, expected);
    } else {
      assert.deepOwnInclude(card, expected);
    }
  }

  /** Asserts the Media response. */
  assertMedia(
    expected: protos.google.actions.sdk.v2.conversation.IMedia,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertMedia ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const media = this.getMedia(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(media, expected);
    } else {
      assert.deepOwnInclude(media, expected);
    }
  }

  /** Asserts the Collection response. */
  assertCollection(
    expected: protos.google.actions.sdk.v2.conversation.ICollection,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertCollection ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const collection = this.getCollection(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(collection, expected);
    } else {
      assert.deepOwnInclude(collection, expected);
    }
  }

  /** Asserts the Image response. */
  assertImage(
    expected: protos.google.actions.sdk.v2.conversation.IImage,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertImage ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const image = this.getImage(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(image, expected);
    } else {
      assert.deepOwnInclude(image, expected);
    }
  }

  /** Asserts the Table response. */
  assertTable(
    expected: protos.google.actions.sdk.v2.conversation.ITable,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertTable ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const table = this.getTable(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(table, expected);
    } else {
      assert.deepOwnInclude(table, expected);
    }
  }

  /** Asserts the List response. */
  assertList(
    expected: protos.google.actions.sdk.v2.conversation.IList,
    requireExact = false,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `assertList ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const list = this.getList(checkedResponse!);
    if (requireExact) {
      assert.deepEqual(list, expected);
    } else {
      assert.deepOwnInclude(list, expected);
    }
  }

  /**
   * Asserts the expected intents for the checked query using the matchIntents
   * API call.
   */
  async assertTopMatchedIntent(
    query: string,
    expectedIntent: string,
    requiredPlace = 1,
    queryLanguage: string
  ) {
    const matchedIntents = await this.getMatchIntentsList(query, queryLanguage);
    if (!matchedIntents) {
      this.throwError(`Query ${query} did not match to any intent.`);
    }
    if (
      !matchedIntents ||
      !matchedIntents!.slice(0, requiredPlace - 1).includes(expectedIntent)
    ) {
      this.throwError(
        `Query ${query} expected matched intent ${expectedIntent} is not part of the top ${requiredPlace} matched intents: ${JSON.stringify(
          matchedIntents
        )}`
      );
    }
  }

  /**
   * Asserts that all queries in YAML file matches the expected top matched
   * intent, checked by using the matchIntents API call.
   * Will fail if any of the queries did not match the expected intent.
   */
  async assertMatchIntentsFromYamlFile(
    yamlFile: string,
    queriesLanguage?: string
  ) {
    const fileContents = fs.readFileSync(yamlFile, 'utf8');
    const yamlData = yaml.safeLoad(fileContents) as MatchIntentsTestSuite;
    expect(yamlData, `failed to read file ${yamlFile}`).to.exist;
    expect(yamlData!['testCases'], `Missing 'testCases' from ${yamlFile}`).to
      .exist;
    const failedQueries = [];
    for (const testCase of yamlData!.testCases!) {
      if (!testCase!['query']) {
        throw new Error('YAML file test entry is missing "query" field.');
      }
      if (!testCase!['expectedIntent']) {
        throw new Error(
          'YAML file test entry is missing "expectedIntent" field.'
        );
      }
      let language = yamlData!['defaultLanguage'];
      if (!language) {
        expect(
          queriesLanguage,
          'Failed since assertMatchIntentsFromYamlFile is missing a language'
        ).to.exist;
        language = queriesLanguage;
      }
      const matchResponse = await this.getMatchIntents(
        testCase!.query!,
        language!
      );
      const topMatchedIntentName = this.getTopMatchIntentFromMatchResponse(
        matchResponse
      );
      if (topMatchedIntentName !== testCase!['expectedIntent']) {
        failedQueries.push({
          query: testCase!['query'],
          actual: topMatchedIntentName,
          expected: testCase!['expectedIntent'],
        });
      }
    }
    expect(
      failedQueries,
      `The following queries have failed: ${JSON.stringify(failedQueries)}`
    ).to.be.empty;
  }

  /** Gets the intents for the checked query using the matchIntents API call. */
  async getMatchIntents(
    query: string,
    queryLanguage: string
  ): Promise<protos.google.actions.sdk.v2.IMatchIntentsResponse> {
    const locale =
      queryLanguage ||
      this.getTestInteractionMergedDefaults().deviceProperties!.locale!;
    return this.actionsApiHelper.matchIntents({locale, query});
  }

  /** Gets the matched intents' names using the matchIntents API call. */
  async getMatchIntentsList(
    query: string,
    queryLanguage: string
  ): Promise<string[]> {
    const responseMatchIntents = await this.getMatchIntents(
      query,
      queryLanguage
    );
    expect(
      responseMatchIntents['matchedIntents'],
      'Failed to get matchedIntents section in from getMatchIntents response.'
    ).to.exist;
    return responseMatchIntents!.matchedIntents!.map(intent => {
      return intent.name!;
    });
  }

  // --------------- Getters:
  /** Gets the latest turn full response. */
  getLatestResponse(): protos.google.actions.sdk.v2.ISendInteractionResponse | null {
    return this.latestResponse;
  }

  /**
   * Gets the response Speech (concatenation of the first_simple and last_simple
   * Speech)
   */
  getSpeech(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): string | undefined | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getSpeech ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    if ('speech' in checkedResponse!.output!) {
      return checkedResponse!.output!.speech!.join('');
    }
    return this.getText(checkedResponse!);
  }

  /**
   * Gets the response Text (concatenation of the first_simple and last_simple
   * Text)
   */
  getText(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): string | undefined | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getText ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return checkedResponse!.output!['text'];
  }

  /** Gets the intent, from the response. */
  getIntent(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): string {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getIntent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    let intentName: string | null = null;
    if ('actionsBuilderEvents' in checkedResponse!.diagnostics!) {
      for (const actionsBuilderEvent of checkedResponse!.diagnostics!
        .actionsBuilderEvents!) {
        if (
          actionsBuilderEvent['intentMatch'] &&
          actionsBuilderEvent['intentMatch']['intentId']
        ) {
          intentName = actionsBuilderEvent['intentMatch']['intentId'];
        }
      }
    }
    expect(
      intentName,
      `Unexpected issue: Failed to find intent name in the response ${JSON.stringify(
        checkedResponse
      )}`
    ).to.exist;
    return intentName!;
  }

  /** Gets the current intent parameter value, from the response. */
  getIntentParameter(
    parameterName: string,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): any | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getIntentParameter ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    let intentMatch: protos.google.actions.sdk.v2.IIntentMatch | null = null;
    if ('actionsBuilderEvents' in checkedResponse!.diagnostics!) {
      for (const actionsBuilderEvent of checkedResponse!.diagnostics!
        .actionsBuilderEvents!) {
        if (actionsBuilderEvent['intentMatch']) {
          intentMatch = actionsBuilderEvent['intentMatch'];
        }
      }
    }
    if (
      intentMatch &&
      intentMatch!.intentParameters &&
      parameterName in intentMatch!.intentParameters! &&
      'resolved' in intentMatch!.intentParameters[parameterName]!
    ) {
      return intentMatch!.intentParameters[parameterName]!.resolved;
    }
    return null;
  }

  /** Gets the last scene, from the response. */
  getScene(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): string {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getScene ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return (
      this.getExecutionState(checkedResponse!)?.currentSceneId ||
      constants.UNCHANGED_SCENE
    );
  }

  /** Gets the Prompt. */
  getPrompt(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.IPrompt | undefined | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getContent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return checkedResponse!.output?.actionsBuilderPrompt;
  }

  /**
   * Gets the Canvas Data.
   */
  getSuggestions(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ):
    | protos.google.actions.sdk.v2.conversation.ISuggestion[]
    | undefined
    | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getSuggestions ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return this.getPrompt(response)?.suggestions;
  }

  /** Gets the Prompt Content, if exists. */
  getContent(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.IContent | undefined | null {
    return this.getPrompt(response)?.content;
  }

  /** Gets the Card response, if exists. */
  getCard(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.ICard | undefined | null {
    const content = this.getContent(response);
    return content?.card;
  }

  /** Gets the Image response, if exists. */
  getImage(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.IImage | undefined | null {
    const content = this.getContent(response);
    return content?.image;
  }

  /** Gets the Table response, if exists. */
  getTable(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.ITable | undefined | null {
    const content = this.getContent(response);
    return content?.table;
  }

  /** Gets the Collection response, if exists. */
  getCollection(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.ICollection | undefined | null {
    const content = this.getContent(response);
    return content?.collection;
  }

  /** Gets the List response, if exists. */
  getList(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.IList | undefined | null {
    const content = this.getContent(response);
    return content?.list;
  }

  /** Gets the Media response, if exists. */
  getMedia(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.conversation.IMedia | undefined | null {
    const content = this.getContent(response);
    return content?.media;
  }

  /** Returns whether the conversation ended, based on the response. */
  getIsConversationEnded(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): boolean {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getIsConversationEnded ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    if (!('actionsBuilderEvents' in checkedResponse!.diagnostics!)) {
      return true;
    }
    const actionsBuilderEvent = this.getLatestActionsBuilderEvent(
      checkedResponse!
    );
    return 'endConversation' in actionsBuilderEvent!;
  }

  /**
   * Returns the value of the session param from the response.
   */
  getSessionParam(
    name: string,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): any {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getSessionParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    let value = null;
    const executionState = this.getExecutionState(checkedResponse!);
    if (
      executionState &&
      'sessionStorage' in executionState &&
      name in executionState.sessionStorage!
    ) {
      value = (executionState.sessionStorage as any)[name];
    }
    return value;
  }

  /**
   * Returns the value of the user param from the response.
   */
  getUserParam(
    name: string,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): any {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getUserParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    let value = null;
    const executionState = this.getExecutionState(checkedResponse!);
    if (
      executionState &&
      'userStorage' in executionState &&
      name in executionState.userStorage!
    ) {
      value = (executionState.userStorage as any)[name];
    }
    return value;
  }

  /**
   * Returns the value of the home (household) storage param from the response.
   */
  getHomeParam(
    name: string,
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): any {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getHomeParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    const executionState = this.getExecutionState(checkedResponse!);
    let value = null;
    if (
      executionState &&
      'householdStorage' in executionState &&
      name in executionState.householdStorage!
    ) {
      value = (executionState.householdStorage as any)[name];
    }
    return value;
  }

  /**
   * Gets the Canvas URL from the response.
   */
  getCanvasURL(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): string | undefined | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getCanvasURL ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return checkedResponse!.output!.canvas?.url;
  }

  /**
   * Gets the Canvas Data.
   */
  getCanvasData(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): any[] | undefined | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getCanvasData ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return checkedResponse!.output!.canvas?.data;
  }

  /** Gets the execution state. */
  private getExecutionState(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.IExecutionState | undefined | null {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getExecutionState ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return this.getLatestActionsBuilderEvent(checkedResponse!)?.executionState;
  }

  /** Gets the latest ActionsBuilderEvent. */
  private getLatestActionsBuilderEvent(
    response?: protos.google.actions.sdk.v2.ISendInteractionResponse
  ): protos.google.actions.sdk.v2.IExecutionEvent | undefined {
    const checkedResponse = response || this.latestResponse;
    expect(
      checkedResponse,
      `getActionsBuilderLatestEvent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}`
    ).to.exist;
    return checkedResponse!.diagnostics?.actionsBuilderEvents?.slice(-1)[0];
  }

  /** Returns the i18n value of the key. */
  private i18n(name: string, params?: i18n.Replacements): string {
    if (params) {
      return i18n.__(name, params);
    }
    return i18n.__(name);
  }

  /** Updates the current locale for the i18n util functions. */
  private updateCurrentLocale(locale: string) {
    if (
      constants.SUPPORTED_LOCALES.concat(
        Object.keys(constants.FALLBACK_LOCALES)
      ).indexOf(locale) === -1
    ) {
      this.throwError(
        `The provided locale '${locale}' is not a supported 'Actions On Google' locale.`
      );
      return;
    }
    i18n.setLocale(locale);
  }

  /**
   * Asserts the value matched the expected string or array of string.
   */
  private assertValueCommon(
    value: string,
    expected: string | string[],
    checkName: string,
    args: AssertValueArgs = {}
  ) {
    const isExact = 'isExact' in args ? args.isExact : false;
    const isRegexp = 'isRegexp' in args ? args.isRegexp : false;
    const expectedList = Array.isArray(expected) ? expected : [expected];
    let isMatch = false;
    for (const expectedItem of expectedList) {
      if (isRegexp) {
        let itemRegexpMatch: RegExpMatchArray | null;
        if (isExact) {
          itemRegexpMatch = value.match('^' + expectedItem + '$');
        } else {
          itemRegexpMatch = value.match(expectedItem);
        }
        if (itemRegexpMatch) {
          isMatch = true;
        }
      } else {
        let itemMatch: boolean;
        if (isExact) {
          itemMatch = value === expectedItem;
        } else {
          itemMatch = value.includes(expectedItem);
        }
        isMatch = isMatch || itemMatch;
      }
    }
    if (isMatch) {
      return;
    }
    let errorMessage = `Unexpected ${checkName}.\n --- Actual value is: ${JSON.stringify(
      value
    )}.\n --- Expected`;
    if (isRegexp) {
      errorMessage += ' to regexp match';
    } else {
      errorMessage += ' to match';
    }
    if (Array.isArray(expected)) {
      errorMessage += ' one of';
    }
    errorMessage += ':' + JSON.stringify(expected);
    this.throwError(errorMessage);
  }

  /** Throws an error with a given message. */
  throwError(errorStr: string) {
    console.error(errorStr + '\n  During user query: ' + this.lastUserQuery);
    throw new Error(errorStr + '\n  During user query: ' + this.lastUserQuery);
  }

  /** Gets the text of 'stop' query in the requested locale. */
  private getStopQuery(): string {
    return this.i18n('cancel');
  }

  /** Gets top matched intent name from the MatchedIntent response. */
  private getTopMatchIntentFromMatchResponse(
    matchResponse: protos.google.actions.sdk.v2.IMatchIntentsResponse
  ): string | null {
    expect(
      matchResponse['matchedIntents'],
      'Failed to get matchedIntents section in from getMatchIntents response.'
    ).to.exist;
    if (matchResponse.matchedIntents!.length > 0) {
      const topMatch = matchResponse.matchedIntents![0];
      if ('name' in topMatch) {
        return topMatch.name!;
      }
    }
    return null;
  }

  /** Validates that the response content is valid */
  private validateSendInteractionResponse(
    response: protos.google.actions.sdk.v2.ISendInteractionResponse
  ) {
    expect(response, 'Unexpected API call issue: Response is empty').to.exist;
    expect(
      response!.diagnostics,
      `Unexpected API call issue: Response 'diagnostics' is missing: ${JSON.stringify(
        response
      )}`
    ).to.exist;
    expect(
      response!.output,
      "Unexpected API call issue: Response 'diagnostics' is missing"
    ).to.exist;
  }
}