// Licensed to Cloudera, Inc. under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  Cloudera, Inc. licenses this file
// to you 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
//
//     http://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.

import { Category, CategoryInfo } from './Category';
import { CancellablePromise } from '../../../../../api/cancellablePromise';
import Executor from '../../../../../apps/editor/execution/executor';
import { SqlAnalyzer, SqlAnalyzerProvider } from '../../../../../catalog/analyzer/types';
import DataCatalogEntry, {
  FieldSample,
  FieldSourceMeta,
  SourceMeta,
  TableSourceMeta
} from '../../../../../catalog/DataCatalogEntry';
import { SqlAnalyzerPopularity, SqlAnalyzerPopularitySubType } from '../../../../../catalog/dataCatalog';
import MultiTableEntry, {
  TopAggs,
  TopAggValue,
  TopFilters,
  TopFilterValue,
  TopJoins,
  TopJoinValue
} from '../../../../../catalog/MultiTableEntry';
import SubscriptionTracker from '../../../../../components/utils/SubscriptionTracker';
import { Ace } from '../../../../../ext/ace';
import {
  AutocompleteParseResult,
  ColumnAliasDetails,
  ColumnDetails,
  CommonPopularSuggestion,
  IdentifierChainEntry,
  ParsedTable,
  SubQuery
} from '../../../../../parse/types';

import apiHelper from '../../../../../api/apiHelper';
import dataCatalog from '../../../../../catalog/dataCatalog';
import { SetDetails, SqlReferenceProvider, UdfDetails } from '../../../../../sql/reference/types';
import { hueWindow } from '../../../../../types/types';
import sqlUtils from '../../../../../sql/sqlUtils';
import { matchesType } from '../../../../../sql/reference/typeUtils';
import { cancelActiveRequest } from '../../../../../api/apiUtils';
import { findBrowserConnector, getRootFilePath } from '../../../../../config/hueConfig';
import {
  findUdf,
  getArgumentDetailsForUdf,
  getUdfsWithReturnTypes,
  getReturnTypesForUdf
} from '../../../../../sql/reference/sqlUdfRepository';
import I18n from '../../../../../utils/i18n';
import equalIgnoreCase from '../../../../../utils/string/equalIgnoreCase';

interface ColumnReference {
  type: string;
  sample?: FieldSample[][];
}

export interface ColRefKeywordDetails {
  type: string;
}

export interface FileDetails {
  name: string;
  type: string;
}

export interface CommentDetails {
  comment: string;
}

export interface Suggestion {
  value: string;
  filterValue?: string;
  filterWeight?: number;
  details?:
    | DataCatalogEntry
    | ColRefKeywordDetails
    | ColumnAliasDetails
    | SetDetails
    | UdfDetails
    | FileDetails
    | TopJoinValue
    | TopAggValue
    | TopFilterValue
    | FieldSample
    | CommentDetails
    | { name: string };
  matchComment?: boolean;
  matchIndex?: number;
  matchLength?: number;
  hasCatalogEntry?: boolean;
  meta: string;
  relativePopularity?: number;
  category: CategoryInfo;
  table?: ParsedTable;
  popular?: boolean;
  tableName?: string;
  weightAdjust?: number;
  partitionKey?: boolean;
  isColumn?: boolean;
}

const MetaLabels = {
  AggregateFunction: I18n('aggregate'),
  Alias: I18n('alias'),
  CTE: I18n('cte'),
  Database: I18n('database'),
  Dir: I18n('dir'),
  Filter: I18n('filter'),
  GroupBy: I18n('group by'),
  Join: I18n('join'),
  JoinCondition: I18n('condition'),
  Keyword: I18n('keyword'),
  OrderBy: I18n('order by'),
  Sample: I18n('sample'),
  Table: I18n('table'),
  Variable: I18n('variable'),
  View: I18n('view'),
  Virtual: I18n('virtual')
};

const HIVE_DIALECT = 'hive';
const IMPALA_DIALECT = 'impala';
const PHOENIX_DIALECT = 'phoenix';

const HIVE_VIRTUAL_COLUMNS = [
  'BLOCK__OFFSET__INSIDE__FILE',
  'GROUPING__ID',
  'RAW__DATA__SIZE',
  'ROW__ID',
  'ROW__IS__DELETED',
  'ROW__OFFSET__INSIDE__BLOCK'
];

const locateSubQuery = (subQueries: SubQuery[], subQueryName: string): SubQuery | undefined => {
  if (subQueries) {
    return subQueries.find(knownSubQuery => equalIgnoreCase(knownSubQuery.alias, subQueryName));
  }
};

class AutocompleteResults {
  executor: Executor;
  editor: Ace.Editor;
  temporaryOnly: boolean;
  activeDatabase: string;
  sqlReferenceProvider: SqlReferenceProvider;
  sqlAnalyzer?: SqlAnalyzer;

  parseResult!: AutocompleteParseResult;
  subTracker = new SubscriptionTracker();
  onCancelFunctions: (() => void)[] = [];
  lastKnownRequests: JQueryXHR[] = [];
  cancellablePromises: CancellablePromise<unknown>[] = [];

  constructor(options: {
    sqlReferenceProvider: SqlReferenceProvider;
    sqlAnalyzerProvider?: SqlAnalyzerProvider;
    executor: Executor;
    editor: Ace.Editor;
    temporaryOnly: boolean;
  }) {
    this.sqlReferenceProvider = options.sqlReferenceProvider;
    this.sqlAnalyzer = options.sqlAnalyzerProvider?.getSqlAnalyzer(options.executor.connector());
    this.executor = options.executor;
    this.editor = options.editor;
    this.temporaryOnly = options.temporaryOnly;
    this.activeDatabase = this.executor.database();
  }

  dialect(): string {
    return this.executor.connector().dialect || this.executor.connector().type;
  }

  async update(parseResult: AutocompleteParseResult, suggestions: Suggestion[]): Promise<void[]> {
    let cancelFn;
    while ((cancelFn = this.onCancelFunctions.pop())) {
      cancelFn();
    }
    this.activeDatabase = parseResult.useDatabase || this.executor.database();
    this.parseResult = parseResult;

    if (this.parseResult.udfArgument) {
      await this.adjustForUdfArgument();
    }

    const promises: Promise<void>[] = [];

    const trackPromise = (promise: Promise<Suggestion[]>): Promise<Suggestion[]> => {
      const wrapped = new Promise<void>(resolve => {
        this.onCancelFunctions.push(resolve);
        promise
          .then(newSuggestions => {
            if (newSuggestions && newSuggestions.length) {
              suggestions.push(...newSuggestions);
            }
            resolve();
          })
          .catch(resolve);
      });
      promises.push(wrapped);
      return promise;
    };

    const colRefPromise = this.handleColumnReference();
    const databasesPromise = this.loadDatabases();

    trackPromise(this.handleKeywords());
    trackPromise(this.handleColRefKeywords(colRefPromise));
    trackPromise(this.handleIdentifiers());
    trackPromise(this.handleColumnAliases());
    trackPromise(this.handleCommonTableExpressions());
    trackPromise(this.handleOptions());
    trackPromise(this.handleFunctions(colRefPromise));
    trackPromise(this.handleDatabases(databasesPromise));
    const tablesPromise = trackPromise(this.handleTables(databasesPromise));
    const columnsPromise = trackPromise(this.handleColumns(colRefPromise, tablesPromise));
    trackPromise(this.handleValues(colRefPromise));
    trackPromise(this.handlePaths());

    if (!this.temporaryOnly) {
      trackPromise(this.handleJoins());
      trackPromise(this.handleJoinConditions());
      trackPromise(this.handleAggregateFunctions());
      trackPromise(this.handleGroupBys(columnsPromise));
      trackPromise(this.handleOrderBys(columnsPromise));
      trackPromise(this.handleFilters());
      trackPromise(this.handlePopularTables(tablesPromise));
      trackPromise(this.handlePopularColumns(columnsPromise));
    }

    return Promise.all(promises);
  }

  async adjustForUdfArgument(): Promise<void> {
    const foundArgumentDetails = (await getArgumentDetailsForUdf(
      this.sqlReferenceProvider,
      this.executor.connector(),
      this.parseResult.udfArgument!.name,
      this.parseResult.udfArgument!.position
    )) || [{ type: 'T' }];

    if (foundArgumentDetails.length === 0 && this.parseResult.suggestColumns) {
      delete this.parseResult.suggestColumns;
      delete this.parseResult.suggestKeyValues;
      delete this.parseResult.suggestValues;
      delete this.parseResult.suggestFunctions;
      delete this.parseResult.suggestIdentifiers;
      delete this.parseResult.suggestKeywords;
    } else if (foundArgumentDetails[0].type !== 'BOOLEAN') {
      if (this.parseResult.suggestFunctions && !this.parseResult.suggestFunctions.types) {
        this.parseResult.suggestFunctions.types = foundArgumentDetails.map(details => details.type);
      }
      if (this.parseResult.suggestColumns && !this.parseResult.suggestColumns.types) {
        this.parseResult.suggestColumns.types = foundArgumentDetails.map(details => details.type);
      }
    }
    if (foundArgumentDetails.length) {
      const keywords: ({ value: string; weight: number } | string)[] = [];
      foundArgumentDetails.forEach(details => {
        if (details.keywords) {
          keywords.push(...details.keywords);
        }
      });
      if (keywords.length) {
        if (!this.parseResult.suggestKeywords) {
          this.parseResult.suggestKeywords = [];
        }
        this.parseResult.suggestKeywords.push(
          ...keywords.map(keyword => {
            if (typeof keyword === 'object') {
              return keyword;
            }
            return {
              value: keyword,
              weight: 10000 // Bubble up units etc on top
            };
          })
        );
      }
    }
  }

  /**
   * For some suggestions the column type is needed, for instance with functions we should only suggest
   * columns that matches the argument type, cos(|) etc.
   *
   * The promise will always resolve, and the default values is { type: 'T' }
   */
  async handleColumnReference(): Promise<ColumnReference> {
    if (!this.parseResult.colRef) {
      return { type: 'T' };
    }

    const foundVarRef = this.parseResult.colRef.identifierChain.some(
      identifier => typeof identifier.name !== 'undefined' && identifier.name.indexOf('${') === 0
    );

    if (!foundVarRef) {
      try {
        const catalogEntry = await this.fetchFieldForIdentifierChain(
          this.parseResult.colRef.identifierChain
        );
        if (catalogEntry) {
          const sourceMeta = await new Promise<SourceMeta>((resolve, reject) => {
            this.onCancelFunctions.push(reject);
            const sourceMetaPromise = catalogEntry.getSourceMeta({
              silenceErrors: true,
              cancellable: true
            });
            this.cancellablePromises.push(sourceMetaPromise);
            sourceMetaPromise.then(resolve).catch(reject);
          });
          if ((<FieldSourceMeta>sourceMeta).type) {
            return {
              type: (<FieldSourceMeta>sourceMeta).type,
              sample: (<FieldSourceMeta>sourceMeta).sample // sourceMeta.sample is used in handleValues.
            };
          }
        }
      } catch (err) {}
    }
    return { type: 'T' };
  }

  async loadDatabases(): Promise<DataCatalogEntry[]> {
    try {
      const entry = await new Promise<DataCatalogEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            path: [],
            temporaryOnly: this.temporaryOnly
          })
          .then(resolve)
          .catch(reject);
      });

      return await new Promise((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const childrenPromise = entry.getChildren({ silenceErrors: true, cancellable: true });
        this.cancellablePromises.push(childrenPromise);
        childrenPromise.then(resolve).catch(reject);
      });
    } catch (err) {
      return [];
    }
  }

  async handleKeywords(): Promise<Suggestion[]> {
    const suggestKeywords = this.parseResult.suggestKeywords;
    if (!suggestKeywords) {
      return [];
    }
    return suggestKeywords.map(keyword => ({
      value: this.parseResult.lowerCase ? keyword.value.toLowerCase() : keyword.value,
      meta: MetaLabels.Keyword,
      category: Category.Keyword,
      weightAdjust: keyword.weight,
      popular: false
    }));
  }

  async handleColRefKeywords(colRefPromise: Promise<ColumnReference>): Promise<Suggestion[]> {
    const suggestColRefKeywords = this.parseResult.suggestColRefKeywords;
    if (!suggestColRefKeywords) {
      return [];
    }
    // Wait for the column reference type to be resolved to pick the right keywords
    const colRef = await colRefPromise;
    const colRefKeywordSuggestions: Suggestion[] = [];
    Object.keys(suggestColRefKeywords).forEach(typeForKeywords => {
      if (matchesType(this.dialect(), [typeForKeywords], [colRef.type.toUpperCase()])) {
        suggestColRefKeywords[typeForKeywords].forEach(keyword => {
          colRefKeywordSuggestions.push({
            value: this.parseResult.lowerCase ? keyword.toLowerCase() : keyword,
            meta: MetaLabels.Keyword,
            category: Category.ColRefKeyword,
            popular: false,
            details: {
              type: colRef.type
            }
          });
        });
      }
    });
    return colRefKeywordSuggestions;
  }

  async handleIdentifiers(): Promise<Suggestion[]> {
    const suggestIdentifiers = this.parseResult.suggestIdentifiers;
    if (!suggestIdentifiers) {
      return [];
    }
    return suggestIdentifiers.map(identifier => ({
      value: identifier.name,
      meta: identifier.type,
      category: Category.Identifier,
      popular: false
    }));
  }

  async handleColumnAliases(): Promise<Suggestion[]> {
    const suggestColumnAliases = this.parseResult.suggestColumnAliases;
    if (!suggestColumnAliases) {
      return [];
    }
    const columnAliasSuggestions: Suggestion[] = [];

    for (const columnAlias of suggestColumnAliases) {
      const type = columnAlias.types && columnAlias.types.length === 1 ? columnAlias.types[0] : 'T';
      if (type === 'COLREF') {
        columnAliasSuggestions.push({
          value: columnAlias.name,
          meta: MetaLabels.Alias,
          category: Category.Column,
          popular: false,
          details: columnAlias
        });
      } else if (type === 'UDFREF' && columnAlias.udfRef) {
        try {
          const types = await getReturnTypesForUdf(
            this.sqlReferenceProvider,
            this.executor.connector(),
            columnAlias.udfRef
          );
          const resolvedType = types.length === 1 ? types[0] : 'T';
          columnAliasSuggestions.push({
            value: columnAlias.name,
            meta: resolvedType,
            category: Category.Column,
            popular: false,
            details: columnAlias
          });
        } catch (err) {}
      } else {
        columnAliasSuggestions.push({
          value: columnAlias.name,
          meta: type,
          category: Category.Column,
          popular: false,
          details: columnAlias
        });
      }
    }

    return columnAliasSuggestions;
  }

  async handleCommonTableExpressions(): Promise<Suggestion[]> {
    const suggestCommonTableExpressions = this.parseResult.suggestCommonTableExpressions;
    if (!suggestCommonTableExpressions) {
      return [];
    }
    const commonTableExpressionSuggestions: Suggestion[] = [];
    suggestCommonTableExpressions.forEach(expression => {
      let prefix = expression.prependQuestionMark ? '? ' : '';
      if (expression.prependFrom) {
        prefix += this.parseResult.lowerCase ? 'from ' : 'FROM ';
      }
      commonTableExpressionSuggestions.push({
        value: prefix + expression.name,
        filterValue: expression.name,
        meta: MetaLabels.CTE,
        category: Category.CTE,
        popular: false
      });
    });

    return commonTableExpressionSuggestions;
  }

  async handleOptions(): Promise<Suggestion[]> {
    if (!this.parseResult.suggestSetOptions) {
      return [];
    }
    try {
      const setOptions = await this.sqlReferenceProvider.getSetOptions(
        this.executor.connector().dialect || 'generic'
      );
      return Object.keys(setOptions).map(name => ({
        category: Category.Option,
        value: name,
        meta: '',
        popular: false,
        weightAdjust: 0,
        details: setOptions[name]
      }));
    } catch (err) {
      return [];
    }
  }

  async handleFunctions(colRefPromise: Promise<ColumnReference>): Promise<Suggestion[]> {
    const suggestFunctions = this.parseResult.suggestFunctions;
    if (!suggestFunctions) {
      return [];
    }

    let suggestions: Suggestion[] = [];
    if (
      suggestFunctions.types &&
      (suggestFunctions.types[0] === 'COLREF' || suggestFunctions.types[0] === 'UDFREF')
    ) {
      const getUdfsForTypes = async (types: string[]): Promise<Suggestion[]> => {
        try {
          const functionsToSuggest = await getUdfsWithReturnTypes(
            this.sqlReferenceProvider,
            this.executor.connector(),
            types,
            !!this.parseResult.suggestAggregateFunctions,
            !!this.parseResult.suggestAnalyticFunctions
          );

          const firstType = types[0].toUpperCase();

          const functionSuggestions: Suggestion[] = [];
          functionsToSuggest.map(udf => ({
            category: Category.UDF,
            value: udf.name + '()',
            meta: udf.returnTypes.join('|'),
            weightAdjust:
              firstType !== 'T' && udf.returnTypes.some(otherType => otherType === firstType)
                ? 1
                : 0,
            popular: false,
            details: udf
          }));

          return functionSuggestions;
        } catch (err) {
          return [];
        }
      };

      let types = ['T'];
      try {
        if (suggestFunctions.types[0] === 'COLREF') {
          const colRef = await colRefPromise;
          types = [colRef.type.toUpperCase()];
        } else if (suggestFunctions.udfRef) {
          types = await getReturnTypesForUdf(
            this.sqlReferenceProvider,
            this.executor.connector(),
            suggestFunctions.udfRef
          );
        }
      } catch (err) {}
      suggestions = await getUdfsForTypes(types);
    } else {
      const types = suggestFunctions.types || ['T'];

      try {
        const functionsToSuggest = await getUdfsWithReturnTypes(
          this.sqlReferenceProvider,
          this.executor.connector(),
          types,
          !!this.parseResult.suggestAggregateFunctions,
          !!this.parseResult.suggestAnalyticFunctions
        );
        suggestions = functionsToSuggest.map(udf => ({
          category: Category.UDF,
          value: udf.name + '()',
          meta: udf.returnTypes.join('|'),
          weightAdjust:
            types[0].toUpperCase() !== 'T' &&
            udf.returnTypes.some(otherType => otherType === types[0].toUpperCase())
              ? 1
              : 0,
          popular: false,
          details: udf
        }));
      } catch (err) {}
    }

    return suggestions;
  }

  async handleDatabases(databasesPromise: Promise<DataCatalogEntry[]>): Promise<Suggestion[]> {
    const suggestDatabases = this.parseResult.suggestDatabases;
    if (!suggestDatabases) {
      return [];
    }
    const databaseSuggestions: Suggestion[] = [];
    try {
      let prefix = suggestDatabases.prependQuestionMark ? '? ' : '';
      if (suggestDatabases.prependFrom) {
        prefix += this.parseResult.lowerCase ? 'from ' : 'FROM ';
      }

      const catalogEntries = await databasesPromise;

      for (const dbEntry of catalogEntries) {
        const name = dbEntry.name;
        if (name !== '') {
          databaseSuggestions.push({
            value:
              prefix +
              (await sqlUtils.backTickIfNeeded(
                this.executor.connector(),
                name,
                this.sqlReferenceProvider
              )) +
              (suggestDatabases.appendDot ? '.' : ''),
            filterValue: name,
            meta: MetaLabels.Database,
            category: Category.Database,
            popular: false,
            hasCatalogEntry: true,
            details: dbEntry
          });
        }
      }
    } catch (err) {}

    return databaseSuggestions;
  }

  async handleTables(databasesPromise: Promise<DataCatalogEntry[]>): Promise<Suggestion[]> {
    const suggestTables = this.parseResult.suggestTables;
    if (!suggestTables) {
      return [];
    }

    const getTableSuggestions = async (): Promise<Suggestion[]> => {
      let prefix = suggestTables.prependQuestionMark ? '? ' : '';
      if (suggestTables.prependFrom) {
        prefix += this.parseResult.lowerCase ? 'from ' : 'FROM ';
      }

      const database =
        suggestTables.identifierChain && suggestTables.identifierChain.length === 1
          ? suggestTables.identifierChain[0].name
          : this.activeDatabase;

      const tableSuggestions: Suggestion[] = [];

      try {
        const dbEntry = await new Promise<DataCatalogEntry>((resolve, reject) => {
          this.onCancelFunctions.push(reject);
          dataCatalog
            .getEntry({
              namespace: this.executor.namespace(),
              compute: this.executor.compute(),
              connector: this.executor.connector(),
              path: [database],
              temporaryOnly: this.temporaryOnly
            })
            .then(resolve)
            .catch(reject);
        });

        const tableEntries = await new Promise<DataCatalogEntry[]>((resolve, reject) => {
          this.onCancelFunctions.push(reject);
          const childrenPromise = dbEntry.getChildren({ silenceErrors: true, cancellable: true });
          this.cancellablePromises.push(childrenPromise);
          childrenPromise.then(resolve).catch(reject);
        });

        for (const tableEntry of tableEntries) {
          if (
            (suggestTables.onlyTables && !tableEntry.isTable()) ||
            (suggestTables.onlyViews && !tableEntry.isView())
          ) {
            continue;
          }
          const name = tableEntry.name;
          tableSuggestions.push({
            value:
              prefix +
              (await sqlUtils.backTickIfNeeded(
                this.executor.connector(),
                name,
                this.sqlReferenceProvider
              )),
            filterValue: name,
            tableName: name,
            meta: tableEntry.isView() ? MetaLabels.View : MetaLabels.Table,
            category: Category.Table,
            popular: false,
            hasCatalogEntry: true,
            details: tableEntry
          });
        }
      } catch (err) {}

      return tableSuggestions;
    };

    let tableSuggestions: Suggestion[] = [];
    if (
      this.dialect() === IMPALA_DIALECT &&
      suggestTables.identifierChain &&
      suggestTables.identifierChain.length === 1
    ) {
      try {
        const databases = await databasesPromise;
        const foundDb = databases.find(dbEntry =>
          equalIgnoreCase(dbEntry.name, suggestTables.identifierChain![0].name)
        );
        if (foundDb) {
          tableSuggestions = await getTableSuggestions();
        } else {
          this.parseResult.suggestColumns = {
            tables: [{ identifierChain: suggestTables.identifierChain }]
          };
        }
      } catch (err) {}
    } else if (
      this.dialect() === IMPALA_DIALECT &&
      suggestTables.identifierChain &&
      suggestTables.identifierChain.length > 1
    ) {
      this.parseResult.suggestColumns = {
        tables: [{ identifierChain: suggestTables.identifierChain }]
      };
    } else {
      tableSuggestions = await getTableSuggestions();
    }

    return tableSuggestions;
  }

  async handleColumns(
    colRefPromise: Promise<ColumnReference>,
    tablesPromise: Promise<Suggestion[]>
  ): Promise<Suggestion[]> {
    try {
      await tablesPromise;
    } catch (err) {}

    const suggestColumns = this.parseResult.suggestColumns;
    if (!suggestColumns) {
      return [];
    }

    const columnSuggestions: Suggestion[] = [];
    // For multiple tables we need to merge and make sure identifiers are unique
    const columnPromises: Promise<void>[] = [];

    let types = ['T'];
    try {
      if (suggestColumns.types && suggestColumns.types[0] === 'COLREF') {
        const colRef = await colRefPromise;
        types = [colRef.type.toUpperCase()];
      } else if (
        suggestColumns.types &&
        suggestColumns.types[0] === 'UDFREF' &&
        suggestColumns.udfRef
      ) {
        types = await getReturnTypesForUdf(
          this.sqlReferenceProvider,
          this.executor.connector(),
          suggestColumns.udfRef
        );
      }
    } catch (err) {}

    suggestColumns.tables.forEach(table => {
      columnPromises.push(this.addColumns(table, types, columnSuggestions));
    });

    try {
      await Promise.all(columnPromises);
    } catch (err) {}

    AutocompleteResults.mergeColumns(columnSuggestions);

    if (this.dialect() === HIVE_DIALECT && /[^.]$/.test(this.editor.getTextBeforeCursor())) {
      HIVE_VIRTUAL_COLUMNS.forEach(virtualColumn => {
        columnSuggestions.push({
          value: virtualColumn,
          meta: MetaLabels.Virtual,
          category: Category.VirtualColumn,
          popular: false,
          details: { name: virtualColumn }
        });
      });
    }

    return columnSuggestions;
  }

  async addCteColumns(table: ParsedTable, columnSuggestions: Suggestion[]): Promise<void> {
    const commonTableExpressions = this.parseResult.commonTableExpressions;
    if (!commonTableExpressions) {
      return;
    }
    const cte = commonTableExpressions.find(cte =>
      equalIgnoreCase(cte.alias, table.identifierChain[0].cte)
    );
    if (!cte) {
      return;
    }
    for (const column of cte.columns) {
      const type = column.type && column.type !== 'COLREF' ? column.type : 'T';
      if (column.alias) {
        columnSuggestions.push({
          value: await sqlUtils.backTickIfNeeded(
            this.executor.connector(),
            column.alias,
            this.sqlReferenceProvider
          ),
          filterValue: column.alias,
          meta: type,
          category: Category.Column,
          table: table,
          popular: false,
          details: column
        });
      } else if (
        typeof column.identifierChain !== 'undefined' &&
        column.identifierChain.length > 0 &&
        typeof column.identifierChain[column.identifierChain.length - 1].name !== 'undefined'
      ) {
        columnSuggestions.push({
          value: await sqlUtils.backTickIfNeeded(
            this.executor.connector(),
            column.identifierChain[column.identifierChain.length - 1].name,
            this.sqlReferenceProvider
          ),
          filterValue: column.identifierChain[column.identifierChain.length - 1].name,
          meta: type,
          category: Category.Column,
          table: table,
          popular: false,
          details: column
        });
      }
    }
  }

  async addSubQueryColumns(table: ParsedTable, columnSuggestions: Suggestion[]): Promise<void> {
    if (!table.identifierChain.length || !table.identifierChain[0].subQuery) {
      return;
    }
    const foundSubQuery = locateSubQuery(
      this.parseResult.subQueries,
      table.identifierChain[0].subQuery
    );

    const addSubQueryColumnsRecursive = async (subQueryColumns: ColumnDetails[]): Promise<void> => {
      const connector = this.executor.connector();
      for (const column of subQueryColumns) {
        if (column.alias || column.identifierChain) {
          // TODO: Potentially fetch column types for sub-queries, possible performance hit.
          const type =
            typeof column.type !== 'undefined' && column.type !== 'COLREF' ? column.type : 'T';
          if (column.alias) {
            columnSuggestions.push({
              value: await sqlUtils.backTickIfNeeded(
                connector,
                column.alias,
                this.sqlReferenceProvider
              ),
              filterValue: column.alias,
              meta: type,
              category: Category.Column,
              table: table,
              popular: false,
              details: column
            });
          } else if (column.identifierChain && column.identifierChain.length > 0) {
            columnSuggestions.push({
              value: await sqlUtils.backTickIfNeeded(
                connector,
                column.identifierChain[column.identifierChain.length - 1].name,
                this.sqlReferenceProvider
              ),
              filterValue: column.identifierChain[column.identifierChain.length - 1].name,
              meta: type,
              category: Category.Column,
              table: table,
              popular: false,
              details: column
            });
          }
        } else if (foundSubQuery && column.subQuery && foundSubQuery.subQueries) {
          const foundNestedSubQuery = locateSubQuery(foundSubQuery.subQueries, column.subQuery);
          if (foundNestedSubQuery) {
            await addSubQueryColumnsRecursive(foundNestedSubQuery.columns);
          }
        }
      }
    };
    if (foundSubQuery && foundSubQuery.columns.length > 0) {
      await addSubQueryColumnsRecursive(foundSubQuery.columns);
    }
  }

  async addColumns(
    table: ParsedTable,
    types: string[],
    columnSuggestions: Suggestion[]
  ): Promise<void> {
    if (
      typeof table.identifierChain !== 'undefined' &&
      table.identifierChain.length === 1 &&
      typeof table.identifierChain[0].cte !== 'undefined'
    ) {
      if (
        typeof this.parseResult.commonTableExpressions !== 'undefined' &&
        this.parseResult.commonTableExpressions.length > 0
      ) {
        await this.addCteColumns(table, columnSuggestions);
      }
    } else if (
      typeof table.identifierChain !== 'undefined' &&
      table.identifierChain.length === 1 &&
      typeof table.identifierChain[0].subQuery !== 'undefined'
    ) {
      await this.addSubQueryColumns(table, columnSuggestions);
    } else if (typeof table.identifierChain !== 'undefined') {
      const addColumnsFromEntry = async (dataCatalogEntry: DataCatalogEntry): Promise<void> => {
        const sourceMeta = (await new Promise<SourceMeta>((resolve, reject) => {
          this.onCancelFunctions.push(reject);
          const sourceMetaPromise = dataCatalogEntry.getSourceMeta({
            silenceErrors: true,
            cancellable: true
          });
          this.cancellablePromises.push(sourceMetaPromise);
          sourceMetaPromise.then(resolve).catch(reject);
        })) as FieldSourceMeta;

        const childEntries = await new Promise<DataCatalogEntry[]>((resolve, reject) => {
          this.onCancelFunctions.push(reject);
          const childrenPromise = dataCatalogEntry.getChildren({
            silenceErrors: true,
            cancellable: true
          });
          this.cancellablePromises.push(childrenPromise);
          childrenPromise.then(resolve).catch(reject);
        });

        for (const childEntry of childEntries) {
          let name = await sqlUtils.backTickIfNeeded(
            this.executor.connector(),
            childEntry.name,
            this.sqlReferenceProvider
          );
          if (this.dialect() === HIVE_DIALECT && (childEntry.isArray() || childEntry.isMap())) {
            name += '[]';
          }
          if (
            matchesType(this.dialect(), types, [childEntry.getType().toUpperCase()]) ||
            matchesType(this.dialect(), [childEntry.getType().toUpperCase()], types) ||
            childEntry.getType() === 'column' ||
            childEntry.isComplex()
          ) {
            columnSuggestions.push({
              value: name,
              meta: childEntry.getType(),
              table: table,
              category: Category.Column,
              popular: false,
              weightAdjust:
                types[0].toUpperCase() !== 'T' &&
                types.some(type => equalIgnoreCase(type, childEntry.getType()))
                  ? 1
                  : 0,
              hasCatalogEntry: true,
              details: childEntry
            });
          }
        }
        if (
          this.dialect() === HIVE_DIALECT &&
          (dataCatalogEntry.isArray() || dataCatalogEntry.isMap())
        ) {
          // Remove 'item' or 'value' and 'key' for Hive
          columnSuggestions.pop();
          if (dataCatalogEntry.isMap()) {
            columnSuggestions.pop();
          }
        }

        const complexExtras =
          (sourceMeta.value && sourceMeta.value.fields) ||
          (sourceMeta.item && sourceMeta.item.fields);
        if (
          (this.dialect() === IMPALA_DIALECT || this.dialect() === HIVE_DIALECT) &&
          complexExtras
        ) {
          complexExtras.forEach(field => {
            const fieldType =
              field.type.indexOf('<') !== -1
                ? field.type.substring(0, field.type.indexOf('<'))
                : field.type;
            columnSuggestions.push({
              value: field.name,
              meta: fieldType,
              table: table,
              category: Category.Column,
              popular: false,
              weightAdjust:
                types[0].toUpperCase() !== 'T' &&
                types.some(type => equalIgnoreCase(type, fieldType))
                  ? 1
                  : 0,
              hasCatalogEntry: false,
              details: field
            });
          });
        }
      };

      const suggestcolumns = this.parseResult.suggestColumns;
      const identifierChain =
        (suggestcolumns && suggestcolumns.identifierChain) || table.identifierChain;
      try {
        const entry = await this.fetchFieldForIdentifierChain(identifierChain);
        if (entry) {
          await addColumnsFromEntry(entry);
        }
      } catch (err) {}
    }
  }

  static mergeColumns(columnSuggestions: Suggestion[]): void {
    columnSuggestions.sort((a, b) => a.value.localeCompare(b.value));

    for (let i = 0; i < columnSuggestions.length; i++) {
      const suggestion = columnSuggestions[i];
      suggestion.isColumn = true;
      let hasDuplicates = false;
      for (
        i;
        i + 1 < columnSuggestions.length && columnSuggestions[i + 1].value === suggestion.value;
        i++
      ) {
        const nextTable = columnSuggestions[i + 1].table;
        if (nextTable && nextTable.alias) {
          columnSuggestions[i + 1].value = nextTable.alias + '.' + columnSuggestions[i + 1].value;
        } else if (nextTable && nextTable.identifierChain && nextTable.identifierChain.length) {
          const previousIdentifier =
            nextTable.identifierChain[nextTable.identifierChain.length - 1];
          if (typeof previousIdentifier.name !== 'undefined') {
            columnSuggestions[i + 1].value =
              previousIdentifier.name + '.' + columnSuggestions[i + 1].value;
          } else if (typeof previousIdentifier.subQuery !== 'undefined') {
            columnSuggestions[i + 1].value =
              previousIdentifier.subQuery + '.' + columnSuggestions[i + 1].value;
          }
        }
        hasDuplicates = true;
      }
      if (suggestion.table && suggestion.table.alias) {
        suggestion.value = suggestion.table.alias + '.' + suggestion.value;
      } else if (
        hasDuplicates &&
        suggestion.table &&
        suggestion.table.identifierChain &&
        suggestion.table.identifierChain.length
      ) {
        const lastIdentifier =
          suggestion.table.identifierChain[suggestion.table.identifierChain.length - 1];
        if (typeof lastIdentifier.name !== 'undefined') {
          suggestion.value = lastIdentifier.name + '.' + suggestion.value;
        } else if (typeof lastIdentifier.subQuery !== 'undefined') {
          suggestion.value = lastIdentifier.subQuery + '.' + suggestion.value;
        }
      }
    }
  }

  async handleValues(colRefPromise: Promise<ColumnReference>): Promise<Suggestion[]> {
    const suggestValues = this.parseResult.suggestValues;
    if (!suggestValues) {
      return [];
    }
    const valueSuggestions: Suggestion[] = [];
    const colRefResult = this.parseResult.colRef;
    if (colRefResult && colRefResult.identifierChain) {
      valueSuggestions.push({
        value:
          '${' + colRefResult.identifierChain[colRefResult.identifierChain.length - 1].name + '}',
        meta: MetaLabels.Variable,
        category: Category.Variable,
        popular: false
      });
    }

    const colRef = await colRefPromise;

    if (colRef.sample) {
      const isString = colRef.type === 'string';
      const startQuote = suggestValues.partialQuote ? '' : "'";
      const endQuote =
        typeof suggestValues.missingEndQuote !== 'undefined' && !suggestValues.missingEndQuote
          ? ''
          : suggestValues.partialQuote || "'";
      colRef.sample.forEach(sample => {
        valueSuggestions.push({
          value: isString ? startQuote + sample[0] + endQuote : String(sample[0]),
          meta: MetaLabels.Sample,
          category: Category.Sample,
          popular: false
        });
      });
    }

    return valueSuggestions;
  }

  async handlePaths(): Promise<Suggestion[]> {
    const suggestHdfs = this.parseResult.suggestHdfs;
    if (!suggestHdfs) {
      return [];
    }

    const suggestions: Suggestion[] = [];

    let path = suggestHdfs.path;
    if (path === '') {
      suggestions.push(
        {
          value: 'adl://',
          meta: MetaLabels.Keyword,
          category: Category.Keyword,
          weightAdjust: 0,
          popular: false
        },
        {
          value: 's3a://',
          meta: MetaLabels.Keyword,
          category: Category.Keyword,
          weightAdjust: 0,
          popular: false
        },
        {
          value: 'hdfs://',
          meta: MetaLabels.Keyword,
          category: Category.Keyword,
          weightAdjust: 0,
          popular: false
        },
        {
          value: 'abfs://',
          meta: MetaLabels.Keyword,
          category: Category.Keyword,
          weightAdjust: 0,
          popular: false
        },
        {
          value: '/',
          meta: MetaLabels.Dir,
          category: Category.Files,
          popular: false
        }
      );
    }

    let fetchFunction = 'fetchHdfsPath';

    if (/^s3a:\/\//i.test(path)) {
      fetchFunction = 'fetchS3Path';
      path = path.substring(5);
    } else if (/^adl:\/\//i.test(path)) {
      fetchFunction = 'fetchAdlsPath';
      path = path.substring(5);
    } else if (/^abfs:\/\//i.test(path)) {
      fetchFunction = 'fetchAbfsPath';
      path = path.substring(6);
      if (path === '/') {
        // TODO: connector.id for browsers
        const connector = findBrowserConnector(connector => connector.type === 'abfs');
        const rootPath = connector && getRootFilePath(connector);
        if (rootPath) {
          suggestions.push({
            value: rootPath,
            meta: 'abfs',
            category: Category.Files,
            weightAdjust: 0,
            popular: false
          });
          return suggestions;
        }
      }
    } else if (/^hdfs:\/\//i.test(path)) {
      path = path.substring(6);
    } else if (/^ofs:\/\//i.test(path)) {
      fetchFunction = 'fetchOfsPath';
      path = path.substring(5);
    }

    const parts = path.split('/');
    // Drop the first " or '
    parts.shift();
    // Last one is either partial name or empty
    parts.pop();

    await new Promise<void>(resolve => {
      const apiHelperFn = (<{ [fn: string]: (arg: unknown) => JQueryXHR }>(<unknown>apiHelper))[
        fetchFunction
      ];
      this.lastKnownRequests.push(
        apiHelperFn.bind(apiHelper)({
          pathParts: parts,
          successCallback: (data: { error?: unknown; files: FileDetails[] }) => {
            if (!data.error) {
              data.files.forEach(file => {
                if (file.name !== '..' && file.name !== '.') {
                  let value = path === '' ? '/' + file.name : file.name;
                  if (file.type === 'dir') {
                    value += '/';
                  }
                  suggestions.push({
                    value,
                    meta: file.type,
                    category: Category.Files,
                    popular: false,
                    details: file
                  });
                }
              });
            }
            resolve();
          },
          silenceErrors: true,
          errorCallback: resolve,
          timeout: (<hueWindow>window).AUTOCOMPLETE_TIMEOUT
        })
      );
    });

    return suggestions;
  }

  tableIdentifierChainsToPaths(tables: ParsedTable[]): string[][] {
    const paths: string[][] = [];
    tables.forEach(table => {
      // Could be subquery
      const isTable = table.identifierChain.every(
        identifier => typeof identifier.name !== 'undefined'
      );
      if (isTable) {
        const path = table.identifierChain.map(identifier => identifier.name);
        if (path.length === 1) {
          path.unshift(this.activeDatabase);
        }
        paths.push(path);
      }
    });
    return paths;
  }

  async handleJoins(): Promise<Suggestion[]> {
    const suggestJoins = this.parseResult.suggestJoins;
    if (!(<hueWindow>window).HAS_SQL_ANALYZER || !suggestJoins || !this.sqlAnalyzer) {
      return [];
    }

    const paths = this.tableIdentifierChainsToPaths(suggestJoins.tables);
    if (!paths.length) {
      return [];
    }

    const joinSuggestions: Suggestion[] = [];
    try {
      const multiTableEntry = await new Promise<MultiTableEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getMultiTableEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            paths: paths
          })
          .then(resolve)
          .catch(reject);
      });

      const topJoins = await new Promise<TopJoins>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const topJoinsPromise = multiTableEntry.getTopJoins({
          cancellable: true,
          silenceErrors: true,
          sqlAnalyzer: this.sqlAnalyzer!
        });
        this.cancellablePromises.push(topJoinsPromise);
        topJoinsPromise.then(resolve).catch(reject);
      });

      let totalCount = 0;
      if (topJoins.values) {
        topJoins.values.forEach(value => {
          let joinType = value.joinType || 'join';
          joinType += ' ';
          let suggestionString = suggestJoins.prependJoin
            ? this.parseResult.lowerCase
              ? joinType.toLowerCase()
              : joinType.toUpperCase()
            : '';
          let first = true;

          const existingTables = new Set<string>();
          suggestJoins.tables.forEach(table => {
            existingTables.add(table.identifierChain[table.identifierChain.length - 1].name);
          });

          let joinRequired = false;
          let tablesAdded = false;
          value.tables.forEach(table => {
            const tableParts = table.split('.');
            if (!existingTables.has(tableParts[tableParts.length - 1])) {
              tablesAdded = true;
              const identifier = this.convertSqlAnalyzerQualifiedIdentifier(
                table,
                suggestJoins.tables
              );
              suggestionString += joinRequired
                ? (this.parseResult.lowerCase ? ' join ' : ' JOIN ') + identifier
                : identifier;
              joinRequired = true;
            }
          });

          if (value.joinCols.length > 0) {
            if (!tablesAdded && suggestJoins.prependJoin) {
              suggestionString = '';
              tablesAdded = true;
            }
            suggestionString += this.parseResult.lowerCase ? ' on ' : ' ON ';
          }
          if (tablesAdded) {
            value.joinCols.forEach(joinColPair => {
              if (!first) {
                suggestionString += this.parseResult.lowerCase ? ' and ' : ' AND ';
              }
              suggestionString +=
                this.convertSqlAnalyzerQualifiedIdentifier(
                  joinColPair.columns[0],
                  suggestJoins.tables
                ) +
                ' = ' +
                this.convertSqlAnalyzerQualifiedIdentifier(
                  joinColPair.columns[1],
                  suggestJoins.tables
                );
              first = false;
            });
            totalCount += value.totalQueryCount;
            joinSuggestions.push({
              value: suggestionString,
              meta: MetaLabels.Join,
              category: suggestJoins.prependJoin
                ? Category.PopularJoin
                : Category.PopularActiveJoin,
              popular: true,
              details: value
            });
          }
        });
        joinSuggestions.forEach(suggestion => {
          const details = <TopJoinValue>suggestion.details;
          details.relativePopularity =
            totalCount === 0
              ? details.totalQueryCount
              : Math.round((100 * details.totalQueryCount) / totalCount);
          suggestion.weightAdjust = details.relativePopularity + 1;
        });
      }
    } catch (err) {}

    return joinSuggestions;
  }

  async handleJoinConditions(): Promise<Suggestion[]> {
    const suggestJoinConditions = this.parseResult.suggestJoinConditions;
    if (!(<hueWindow>window).HAS_SQL_ANALYZER || !suggestJoinConditions || !this.sqlAnalyzer) {
      return [];
    }

    const paths = this.tableIdentifierChainsToPaths(suggestJoinConditions.tables);
    if (!paths.length) {
      return [];
    }

    const joinConditionSuggestions: Suggestion[] = [];

    try {
      const multiTableEntry = await new Promise<MultiTableEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getMultiTableEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            paths: paths
          })
          .then(resolve)
          .catch(reject);
      });

      const topJoins = await new Promise<TopJoins>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const topJoinsPromise = multiTableEntry.getTopJoins({
          cancellable: true,
          silenceErrors: true,
          sqlAnalyzer: this.sqlAnalyzer!
        });
        this.cancellablePromises.push(topJoinsPromise);
        topJoinsPromise.then(resolve).catch(reject);
      });

      let totalCount = 0;
      if (topJoins.values) {
        topJoins.values.forEach(value => {
          if (value.joinCols.length > 0) {
            let suggestionString = suggestJoinConditions.prependOn
              ? this.parseResult.lowerCase
                ? 'on '
                : 'ON '
              : '';
            let first = true;
            value.joinCols.forEach(joinColPair => {
              if (!first) {
                suggestionString += this.parseResult.lowerCase ? ' and ' : ' AND ';
              }
              suggestionString +=
                this.convertSqlAnalyzerQualifiedIdentifier(
                  joinColPair.columns[0],
                  suggestJoinConditions.tables
                ) +
                ' = ' +
                this.convertSqlAnalyzerQualifiedIdentifier(
                  joinColPair.columns[1],
                  suggestJoinConditions.tables
                );
              first = false;
            });
            totalCount += value.totalQueryCount;
            joinConditionSuggestions.push({
              value: suggestionString,
              meta: MetaLabels.JoinCondition,
              category: Category.PopularJoinCondition,
              popular: true,
              details: value
            });
          }
        });
        joinConditionSuggestions.forEach(suggestion => {
          const details = <TopJoinValue>suggestion.details;
          details.relativePopularity =
            totalCount === 0
              ? details.totalQueryCount
              : Math.round((100 * details.totalQueryCount) / totalCount);
          suggestion.weightAdjust = details.relativePopularity + 1;
        });
      }
    } catch (err) {}

    return joinConditionSuggestions;
  }

  async handleAggregateFunctions(): Promise<Suggestion[]> {
    const suggestAggregateFunctions = this.parseResult.suggestAggregateFunctions;
    if (
      !(<hueWindow>window).HAS_SQL_ANALYZER ||
      !suggestAggregateFunctions ||
      !suggestAggregateFunctions.tables.length ||
      !this.sqlAnalyzer
    ) {
      return [];
    }

    const paths = this.tableIdentifierChainsToPaths(suggestAggregateFunctions.tables);
    if (!paths.length) {
      return [];
    }

    const aggregateFunctionsSuggestions: Suggestion[] = [];

    try {
      const multiTableEntry = await new Promise<MultiTableEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getMultiTableEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            paths: paths
          })
          .then(resolve)
          .catch(reject);
      });

      const topAggs = await new Promise<TopAggs>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const topAggsDeferred = multiTableEntry.getTopAggs({
          cancellable: true,
          silenceErrors: true,
          sqlAnalyzer: this.sqlAnalyzer!
        });
        this.cancellablePromises.push(topAggsDeferred);
        topAggsDeferred.then(resolve).catch(reject);
      });

      if (!topAggs.values || !topAggs.values.length) {
        return [];
      }

      // Expand all column names to the fully qualified name including db and table.
      topAggs.values.forEach(value => {
        value.aggregateInfo.forEach(info => {
          value.aggregateClause = value.aggregateClause.replace(
            new RegExp('([^.])' + info.columnName, 'gi'),
            '$1' + info.databaseName + '.' + info.tableName + '.' + info.columnName
          );
        });
      });

      // Substitute qualified table identifiers with either alias or table when multiple tables are present or just empty string
      const substitutions: { replace: RegExp; with: string }[] = [];
      suggestAggregateFunctions.tables.forEach(table => {
        const replaceWith = table.alias
          ? table.alias + '.'
          : suggestAggregateFunctions.tables.length > 1
          ? table.identifierChain[table.identifierChain.length - 1].name + '.'
          : '';
        if (table.identifierChain.length > 1) {
          substitutions.push({
            replace: new RegExp(
              table.identifierChain.map(identifier => identifier.name).join('.') + '.',
              'gi'
            ),
            with: replaceWith
          });
        } else if (table.identifierChain.length === 1) {
          substitutions.push({
            replace: new RegExp(
              this.activeDatabase + '.' + table.identifierChain[0].name + '.',
              'gi'
            ),
            with: replaceWith
          });
          substitutions.push({
            replace: new RegExp(table.identifierChain[0].name + '.', 'gi'),
            with: replaceWith
          });
        }
      });

      let totalCount = 0;
      for (let i = 0; i < topAggs.values.length; i++) {
        const value = topAggs.values[i];
        totalCount += value.totalQueryCount;

        let clean = value.aggregateClause;
        substitutions.forEach(substitution => {
          clean = clean.replace(substitution.replace, substitution.with);
        });

        const foundUdfs = await findUdf(
          this.sqlReferenceProvider,
          this.executor.connector(),
          value.aggregateFunction
        );

        // TODO: Support showing multiple UDFs with the same name but different category in the autocomplete details.
        // For instance, trunc appears both for dates with one description and for numbers with another description.
        value.function = foundUdfs.length ? foundUdfs[0] : undefined;

        aggregateFunctionsSuggestions.push({
          value: clean,
          meta: (value.function && value.function.returnTypes.join('|')) || '',
          category: Category.PopularAggregate,
          weightAdjust: Math.min(value.totalQueryCount, 99),
          popular: true,
          details: value
        });
      }

      aggregateFunctionsSuggestions.forEach(suggestion => {
        const details = <TopAggValue>suggestion.details;
        details.relativePopularity =
          totalCount === 0
            ? details.totalQueryCount
            : Math.round((100 * details.totalQueryCount) / totalCount);
        suggestion.weightAdjust = details.relativePopularity + 1;
      });
    } catch (err) {}

    return aggregateFunctionsSuggestions;
  }

  async handlePopularGroupByOrOrderBy(
    sqlAnalyzerAttribute: keyof SqlAnalyzerPopularitySubType,
    suggestSpec: CommonPopularSuggestion,
    columnsPromise: Promise<Suggestion[]>
  ): Promise<Suggestion[]> {
    if (!this.sqlAnalyzer) {
      return [];
    }
    const paths: string[][] = [];
    suggestSpec.tables.forEach(table => {
      if (table.identifierChain) {
        if (table.identifierChain.length === 1 && table.identifierChain[0].name) {
          paths.push([this.activeDatabase, table.identifierChain[0].name]);
        } else if (
          table.identifierChain.length === 2 &&
          table.identifierChain[0].name &&
          table.identifierChain[1].name
        ) {
          paths.push([table.identifierChain[0].name, table.identifierChain[1].name]);
        }
      }
    });

    try {
      const entries = await new Promise<DataCatalogEntry[]>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const popularityPromise = dataCatalog
          .getCatalog(this.executor.connector())
          .loadSqlAnalyzerPopularityForTables({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            paths,
            sqlAnalyzer: this.sqlAnalyzer!,
            silenceErrors: true,
            cancellable: true
          });
        this.cancellablePromises.push(popularityPromise);
        popularityPromise.then(resolve).catch(reject);
      });

      let totalColumnCount = 0;
      const matchedEntries: DataCatalogEntry[] = [];
      const prefix = suggestSpec.prefix
        ? (this.parseResult.lowerCase ? suggestSpec.prefix.toLowerCase() : suggestSpec.prefix) + ' '
        : '';

      entries.forEach(entry => {
        if (!entry.sqlAnalyzerPopularity) {
          return;
        }
        const popularity = entry.sqlAnalyzerPopularity[sqlAnalyzerAttribute];
        if (popularity) {
          totalColumnCount += popularity.columnCount;
          matchedEntries.push(entry);
        }
      });

      if (totalColumnCount > 0) {
        const suggestions: Suggestion[] = [];
        matchedEntries.forEach(entry => {
          const popularity =
            entry.sqlAnalyzerPopularity && entry.sqlAnalyzerPopularity[sqlAnalyzerAttribute];
          if (!popularity) {
            return;
          }
          const filterValue = this.createSqlAnalyzerIdentifierForColumn(
            popularity,
            suggestSpec.tables
          );
          suggestions.push({
            value: prefix + filterValue,
            filterValue: filterValue,
            meta:
              sqlAnalyzerAttribute === 'groupByColumn' ? MetaLabels.GroupBy : MetaLabels.OrderBy,
            category:
              sqlAnalyzerAttribute === 'groupByColumn'
                ? Category.PopularGroupBy
                : Category.PopularOrderBy,
            weightAdjust: Math.round((100 * popularity.columnCount) / totalColumnCount),
            popular: true,
            hasCatalogEntry: false,
            details: entry
          });
        });

        if (prefix === '' && suggestions.length) {
          const columnSuggestions = await columnsPromise;
          const suggestionIndex: { [value: string]: Suggestion } = {};
          suggestions.forEach(suggestion => {
            suggestionIndex[suggestion.value] = suggestion;
          });
          columnSuggestions.forEach(col => {
            const details = <DataCatalogEntry>col.details;
            if (suggestionIndex[details.name]) {
              col.category = suggestionIndex[details.name].category;
            }
          });
          return [];
        }
        return suggestions;
      }
    } catch (err) {}
    return [];
  }

  async handleGroupBys(columnsPromise: Promise<Suggestion[]>): Promise<Suggestion[]> {
    const suggestGroupBys = this.parseResult.suggestGroupBys;
    if (!(<hueWindow>window).HAS_SQL_ANALYZER || !suggestGroupBys) {
      return [];
    }

    return await this.handlePopularGroupByOrOrderBy(
      'groupByColumn',
      suggestGroupBys,
      columnsPromise
    );
  }

  async handleOrderBys(columnsPromise: Promise<Suggestion[]>): Promise<Suggestion[]> {
    const suggestOrderBys = this.parseResult.suggestOrderBys;
    if (!(<hueWindow>window).HAS_SQL_ANALYZER || !suggestOrderBys) {
      return [];
    }
    return await this.handlePopularGroupByOrOrderBy(
      'orderByColumn',
      suggestOrderBys,
      columnsPromise
    );
  }

  async handleFilters(): Promise<Suggestion[]> {
    const suggestFilters = this.parseResult.suggestFilters;
    if (!(<hueWindow>window).HAS_SQL_ANALYZER || !suggestFilters || !this.sqlAnalyzer) {
      return [];
    }

    const paths = this.tableIdentifierChainsToPaths(suggestFilters.tables);
    if (!paths.length) {
      return [];
    }

    const filterSuggestions: Suggestion[] = [];

    try {
      const multiTableEntry = await new Promise<MultiTableEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getMultiTableEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            paths: paths
          })
          .then(resolve)
          .catch(reject);
      });

      const topFilters = await new Promise<TopFilters>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const topFiltersPromise = multiTableEntry.getTopFilters({
          cancellable: true,
          silenceErrors: true,
          sqlAnalyzer: this.sqlAnalyzer!
        });
        this.cancellablePromises.push(topFiltersPromise);
        topFiltersPromise.then(resolve).catch(reject);
      });

      let totalCount = 0;
      if (topFilters.values) {
        topFilters.values.forEach(value => {
          if (value.popularValues) {
            value.popularValues.forEach(popularValue => {
              if (popularValue.group) {
                popularValue.group.forEach(grp => {
                  let compVal = suggestFilters.prefix
                    ? (this.parseResult.lowerCase
                        ? suggestFilters.prefix.toLowerCase()
                        : suggestFilters.prefix) + ' '
                    : '';
                  compVal += this.createSqlAnalyzerIdentifier(
                    value.tableName,
                    grp.columnName,
                    suggestFilters.tables
                  );
                  if (!/^ /.test(grp.op)) {
                    compVal += ' ';
                  }
                  compVal += this.parseResult.lowerCase ? grp.op.toLowerCase() : grp.op;
                  if (!/ $/.test(grp.op)) {
                    compVal += ' ';
                  }
                  compVal += grp.literal;
                  totalCount += popularValue.count;
                  filterSuggestions.push({
                    value: compVal,
                    meta: MetaLabels.Filter,
                    category: Category.PopularFilter,
                    popular: false,
                    details: popularValue
                  });
                });
              }
            });
          }
        });
      }
      filterSuggestions.forEach(suggestion => {
        const details = <TopFilterValue>suggestion.details;
        details.relativePopularity =
          totalCount === 0 ? details.count : Math.round((100 * details.count) / totalCount);
        suggestion.weightAdjust = details.relativePopularity + 1;
      });
    } catch (err) {}

    return filterSuggestions;
  }

  async handlePopularTables(tablesPromise: Promise<Suggestion[]>): Promise<Suggestion[]> {
    const suggestTables = this.parseResult.suggestTables;
    if (!(<hueWindow>window).HAS_SQL_ANALYZER || !suggestTables || !this.sqlAnalyzer) {
      return [];
    }

    const db =
      suggestTables.identifierChain &&
      suggestTables.identifierChain.length === 1 &&
      suggestTables.identifierChain[0].name
        ? suggestTables.identifierChain[0].name
        : this.activeDatabase;

    try {
      const entry = await new Promise<DataCatalogEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            path: [db],
            temporaryOnly: this.temporaryOnly
          })
          .then(resolve)
          .catch(reject);
      });

      const childEntries = await new Promise<DataCatalogEntry[]>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const popularityPromise = entry.loadSqlAnalyzerPopularityForChildren({
          cancellable: true,
          silenceErrors: true,
          sqlAnalyzer: this.sqlAnalyzer!
        });
        this.cancellablePromises.push(popularityPromise);
        popularityPromise.then(resolve).catch(reject);
      });

      let totalPopularity = 0;
      const popularityIndex = new Set<string>();

      childEntries.forEach(childEntry => {
        if (childEntry.sqlAnalyzerPopularity && childEntry.sqlAnalyzerPopularity.popularity) {
          popularityIndex.add(childEntry.name);
          totalPopularity += <number>childEntry.sqlAnalyzerPopularity.popularity;
        }
      });

      if (totalPopularity > 0 && popularityIndex.size > 0) {
        const tableSuggestions = await tablesPromise;
        tableSuggestions.forEach(suggestion => {
          const details = <DataCatalogEntry>suggestion.details;
          if (popularityIndex.has(details.name)) {
            const popularity =
              (details.sqlAnalyzerPopularity && details.sqlAnalyzerPopularity.popularity) || 0;
            suggestion.relativePopularity = Math.round((100 * popularity) / totalPopularity);
            if (suggestion.relativePopularity >= 5) {
              suggestion.popular = true;
            }
            suggestion.weightAdjust = suggestion.relativePopularity;
          }
        });
      }
    } catch (err) {}

    return [];
  }

  async handlePopularColumns(columnsPromise: Promise<Suggestion[]>): Promise<Suggestion[]> {
    const suggestColumns = this.parseResult.suggestColumns;

    if (
      !(<hueWindow>window).HAS_SQL_ANALYZER ||
      !suggestColumns ||
      !suggestColumns.source ||
      !this.sqlAnalyzer
    ) {
      return [];
    }

    let columnSuggestions: Suggestion[] = [];
    try {
      // The columnsDeferred gets resolved synchronously when the data is cached, if not, assume there are some suggestions.
      columnSuggestions = await columnsPromise;
    } catch (err) {}
    if (!columnSuggestions.length) {
      return [];
    }

    try {
      const paths: string[][] = [];
      suggestColumns.tables.forEach(table => {
        if (table.identifierChain && table.identifierChain.length > 0) {
          if (table.identifierChain.length === 1 && table.identifierChain[0].name) {
            paths.push([this.activeDatabase, table.identifierChain[0].name]);
          } else if (
            table.identifierChain.length === 2 &&
            table.identifierChain[0].name &&
            table.identifierChain[1].name
          ) {
            paths.push([table.identifierChain[0].name, table.identifierChain[1].name]);
          }
        }
      });

      const popularEntries = await new Promise<DataCatalogEntry[]>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const popularityPromise = dataCatalog
          .getCatalog(this.executor.connector())
          .loadSqlAnalyzerPopularityForTables({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            paths,
            sqlAnalyzer: this.sqlAnalyzer!,
            silenceErrors: true,
            cancellable: true
          });
        this.cancellablePromises.push(popularityPromise);
        popularityPromise.then(resolve).catch(reject);
      });

      let valueAttribute:
        | keyof Pick<
            SqlAnalyzerPopularitySubType,
            'selectColumn' | 'groupByColumn' | 'orderByColumn'
          >
        | undefined;
      switch (suggestColumns.source) {
        case 'select':
          valueAttribute = 'selectColumn';
          break;
        case 'group by':
          valueAttribute = 'groupByColumn';
          break;
        case 'order by':
          valueAttribute = 'orderByColumn';
      }

      const popularityIndex = new Set<string>();

      popularEntries.forEach(popularEntry => {
        if (
          valueAttribute &&
          popularEntry.sqlAnalyzerPopularity &&
          popularEntry.sqlAnalyzerPopularity[valueAttribute]
        ) {
          popularityIndex.add(popularEntry.getQualifiedPath());
        }
      });

      if (!valueAttribute || popularityIndex.size === 0) {
        return [];
      }

      let totalColumnCount = 0;
      const matchedSuggestions: Suggestion[] = [];
      columnSuggestions.forEach(suggestion => {
        const details = <DataCatalogEntry>suggestion.details;
        if (!valueAttribute) {
          return;
        }
        const popularity =
          details.sqlAnalyzerPopularity && details.sqlAnalyzerPopularity[valueAttribute];
        if (
          popularity &&
          suggestion.hasCatalogEntry &&
          popularityIndex.has(details.getQualifiedPath())
        ) {
          matchedSuggestions.push(suggestion);
          totalColumnCount += popularity.columnCount;
        }
      });
      if (totalColumnCount > 0) {
        matchedSuggestions.forEach(matchedSuggestion => {
          const details = <DataCatalogEntry>matchedSuggestion.details;
          if (!details.sqlAnalyzerPopularity || !valueAttribute) {
            return;
          }
          const popularity = details.sqlAnalyzerPopularity[valueAttribute];
          if (!popularity) {
            return;
          }
          matchedSuggestion.relativePopularity = Math.round(
            (100 * popularity.columnCount) / totalColumnCount
          );
          if (matchedSuggestion.relativePopularity >= 5) {
            matchedSuggestion.popular = true;
          }
          matchedSuggestion.weightAdjust = matchedSuggestion.relativePopularity;
        });
      }
    } catch (err) {}

    return [];
  }

  createSqlAnalyzerIdentifier(
    sqlAnalyzerTableName: string,
    sqlAnalyzerColumnName: string,
    tables: ParsedTable[]
  ): string {
    let path = sqlAnalyzerTableName + '.' + sqlAnalyzerColumnName.split('.').pop();
    for (let i = 0; i < tables.length; i++) {
      let tablePath = '';
      if (tables[i].identifierChain.length === 2) {
        tablePath = tables[i].identifierChain.map(identifier => identifier.name).join('.');
      } else if (tables[i].identifierChain.length === 1) {
        tablePath = this.activeDatabase + '.' + tables[i].identifierChain[0].name;
      }
      if (path.indexOf(tablePath) === 0) {
        path = path.substring(tablePath.length + 1);
        if (tables[i].alias) {
          path = tables[i].alias + '.' + path;
        } else if (tables.length > 0) {
          path = tables[i].identifierChain[tables[i].identifierChain.length - 1].name + '.' + path;
        }
        break;
      }
    }
    return path;
  }

  createSqlAnalyzerIdentifierForColumn(
    sqlAnalyzerColumn: SqlAnalyzerPopularity,
    tables: ParsedTable[]
  ): string {
    for (let i = 0; i < tables.length; i++) {
      if (
        sqlAnalyzerColumn.dbName &&
        (sqlAnalyzerColumn.dbName !== this.activeDatabase ||
          sqlAnalyzerColumn.dbName !== tables[i].identifierChain[0].name)
      ) {
        continue;
      }
      if (
        sqlAnalyzerColumn.tableName &&
        equalIgnoreCase(
          sqlAnalyzerColumn.tableName,
          tables[i].identifierChain[tables[i].identifierChain.length - 1].name
        ) &&
        tables[i].alias
      ) {
        return tables[i].alias + '.' + sqlAnalyzerColumn.columnName;
      }
    }

    if (sqlAnalyzerColumn.dbName && sqlAnalyzerColumn.dbName !== this.activeDatabase) {
      return (
        sqlAnalyzerColumn.dbName +
        '.' +
        sqlAnalyzerColumn.tableName +
        '.' +
        sqlAnalyzerColumn.columnName
      );
    }
    if (tables.length > 1) {
      return sqlAnalyzerColumn.tableName + '.' + sqlAnalyzerColumn.columnName;
    }
    return sqlAnalyzerColumn.columnName || '';
  }

  convertSqlAnalyzerQualifiedIdentifier(
    qualifiedIdentifier: string,
    tables: ParsedTable[]
  ): string {
    const aliases: { qualifiedName: string; alias: string }[] = [];
    let tablesHasDefaultDatabase = false;
    tables.forEach(table => {
      tablesHasDefaultDatabase =
        tablesHasDefaultDatabase ||
        equalIgnoreCase(
          table.identifierChain[0].name.toLowerCase(),
          this.activeDatabase.toLowerCase()
        );
      if (table.alias) {
        aliases.push({
          qualifiedName: table.identifierChain
            .map(identifier => identifier.name)
            .join('.')
            .toLowerCase(),
          alias: table.alias
        });
      }
    });

    for (let i = 0; i < aliases.length; i++) {
      if (qualifiedIdentifier.toLowerCase().indexOf(aliases[i].qualifiedName) === 0) {
        return aliases[i].alias + qualifiedIdentifier.substring(aliases[i].qualifiedName.length);
      } else if (
        qualifiedIdentifier
          .toLowerCase()
          .indexOf(this.activeDatabase.toLowerCase() + '.' + aliases[i].qualifiedName) === 0
      ) {
        return (
          aliases[i].alias +
          qualifiedIdentifier.substring(
            (this.activeDatabase + '.' + aliases[i].qualifiedName).length
          )
        );
      }
    }

    if (
      qualifiedIdentifier.toLowerCase().indexOf(this.activeDatabase.toLowerCase()) === 0 &&
      !tablesHasDefaultDatabase
    ) {
      return qualifiedIdentifier.substring(this.activeDatabase.length + 1);
    }
    if (this.dialect() === HIVE_DIALECT) {
      // Remove DB reference if given for Hive
      const parts = qualifiedIdentifier.split('.');
      if (parts.length > 2) {
        return parts.slice(1).join('.');
      }
    }
    return qualifiedIdentifier;
  }

  /**
   * Helper function to fetch columns/fields given an identifierChain, this also takes care of expanding arrays
   * and maps to match the required format for the API.
   *
   * @param originalIdentifierChain
   */
  async fetchFieldForIdentifierChain(
    originalIdentifierChain: IdentifierChainEntry[]
  ): Promise<DataCatalogEntry | undefined> {
    const path: string[] = [];

    for (let i = 0; i < originalIdentifierChain.length; i++) {
      if (originalIdentifierChain[i].name && !originalIdentifierChain[i].subQuery) {
        path.push(originalIdentifierChain[i].name);
      } else {
        return;
      }
    }

    const fetchFieldRecursive = async (
      remainingPath: string[],
      fetchedPath?: string[]
    ): Promise<DataCatalogEntry> => {
      fetchedPath = fetchedPath || [];

      if (remainingPath.length > 0) {
        let path = remainingPath.shift();
        // path == '' needed for phoenix empty db
        if (path || (this.dialect() === PHOENIX_DIALECT && path === '')) {
          fetchedPath.push(path);
        }
        // Parser sometimes knows if it's a map or array.
        if (
          remainingPath.length > 0 &&
          (remainingPath[0] === 'item' ||
            (<{ name: string }>(<unknown>remainingPath[0])).name === 'value')
        ) {
          path = remainingPath.shift();
          if (path) {
            fetchedPath.push(path);
          }
        }
      }

      const catalogEntry = await new Promise<DataCatalogEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            path: <string[]>fetchedPath,
            temporaryOnly: this.temporaryOnly
          })
          .then(resolve)
          .catch(reject);
      });

      const sourceMeta = await new Promise<SourceMeta>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const sourceMetaPromise = catalogEntry.getSourceMeta({
          silenceErrors: true,
          cancellable: true
        });
        this.cancellablePromises.push(sourceMetaPromise);
        sourceMetaPromise.then(resolve).catch(reject);
      });

      const extendedColumns = (<TableSourceMeta>sourceMeta).extended_columns;
      if (
        this.dialect() === HIVE_DIALECT &&
        extendedColumns &&
        extendedColumns.length === 1 &&
        /^(?:map|array|struct)/i.test(extendedColumns[0].type)
      ) {
        remainingPath.unshift(extendedColumns[0].name);
      }

      if (remainingPath.length) {
        if (/value|item|key/i.test(remainingPath[0])) {
          const path = remainingPath.shift();
          if (path) {
            fetchedPath.push(path);
          }
        } else if ((<FieldSourceMeta>sourceMeta).type === 'array') {
          fetchedPath.push('item');
        } else if ((<FieldSourceMeta>sourceMeta).type === 'map') {
          fetchedPath.push('value');
        }
        return await fetchFieldRecursive(remainingPath, fetchedPath);
      } else {
        return catalogEntry;
      }
    };

    // For Impala the first parts of the identifier chain could be either database or table, either:
    // SELECT | FROM database.table -or- SELECT | FROM table.column

    // For Hive it could be either:
    // SELECT col.struct FROM db.tbl -or- SELECT col.struct FROM tbl
    if (path.length > 1 && (this.dialect() === IMPALA_DIALECT || this.dialect() === HIVE_DIALECT)) {
      const catalogEntry = await new Promise<DataCatalogEntry>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        dataCatalog
          .getEntry({
            namespace: this.executor.namespace(),
            compute: this.executor.compute(),
            connector: this.executor.connector(),
            path: [],
            temporaryOnly: this.temporaryOnly
          })
          .then(resolve)
          .catch(reject);
      });

      const databaseEntries = await new Promise<DataCatalogEntry[]>((resolve, reject) => {
        this.onCancelFunctions.push(reject);
        const childrenPromise = catalogEntry.getChildren({
          silenceErrors: true,
          cancellable: true
        });
        this.cancellablePromises.push(childrenPromise);
        childrenPromise.then(resolve).catch(reject);
      });

      const firstIsDb = databaseEntries.some(dbEntry => equalIgnoreCase(dbEntry.name, path[0]));
      if (!firstIsDb) {
        path.unshift(this.activeDatabase);
      }
    } else if (path.length <= 1) {
      path.unshift(this.activeDatabase);
    }
    return await fetchFieldRecursive(path);
  }

  cancelRequests(): void {
    while (this.lastKnownRequests.length) {
      cancelActiveRequest(this.lastKnownRequests.pop());
    }

    while (this.cancellablePromises.length) {
      const promise = this.cancellablePromises.pop();
      if (promise && promise.cancel) {
        promise.cancel();
      }
    }
  }
}

export default AutocompleteResults;
