/**
 * @copyright Cube Dev, Inc.
 * @license Apache-2.0
 * @fileoverview The `QuestDriver` and related types declaration.
 */

import {
  getEnv,
  assertDataSource,
  escapeStringLiteral,
} from '@cubejs-backend/shared';
import { types, Pool, PoolConfig, FieldDef } from 'pg';
// eslint-disable-next-line import/no-extraneous-dependencies
import { TypeId, TypeFormat } from 'pg-types';
import * as moment from 'moment';
import R from 'ramda';
import {
  BaseDriver, DownloadQueryResultsOptions,
  DownloadTableMemoryData, DriverInterface,
  IndexesSQL, TableStructure, QueryOptions,
  DatabaseStructure,
} from '@cubejs-backend/base-driver';
import { QuestQuery } from './QuestQuery';

const NativeTypeToQuestType: Record<string, string> = R.invertObj(types.builtins);

const timestampDataTypes = [
  types.builtins.DATE,
  types.builtins.TIMESTAMP,
  types.builtins.TIMESTAMPTZ,
];
const timestampTypeParser = (val: string) => moment.utc(val).format(moment.HTML5_FMT.DATETIME_LOCAL_MS);

export type QuestDriverConfiguration = Partial<PoolConfig> & {
  readOnly?: boolean,
};

/**
 * Quest driver class.
 */
export class QuestDriver<Config extends QuestDriverConfiguration = QuestDriverConfiguration>
  extends BaseDriver implements DriverInterface {
  private readonly pool: Pool;

  private readonly config: Partial<Config>;

  public static dialectClass() {
    return QuestQuery;
  }

  /**
   * Returns default concurrency value.
   */
  public static getDefaultConcurrency(): number {
    return 2;
  }

  /**
   * Class constructor.
   */
  public constructor(
    config: QuestDriverConfiguration & {
      /**
       * Data source name.
       */
      dataSource?: string,

      /**
       * Whether this driver is used for pre-aggregations.
       */
      preAggregations?: boolean,

      /**
       * Max pool size value for the [cube]<-->[db] pool.
       */
      maxPoolSize?: number,

      /**
       * Time to wait for a response from a connection after validation
       * request before determining it as not valid. Default - 10000 ms.
       */
      testConnectionTimeout?: number,
    } = {}
  ) {
    super({
      testConnectionTimeout: config.testConnectionTimeout,
    });

    const dataSource =
      config.dataSource ||
      assertDataSource('default');
    const preAggregations = config.preAggregations || false;

    this.pool = new Pool({
      idleTimeoutMillis: 30_000,
      max:
        config.maxPoolSize ||
        getEnv('dbMaxPoolSize', { dataSource, preAggregations }) ||
        4,
      host: getEnv('dbHost', { dataSource, preAggregations }),
      database: getEnv('dbName', { dataSource, preAggregations }),
      port: getEnv('dbPort', { dataSource, preAggregations }),
      user: getEnv('dbUser', { dataSource, preAggregations }),
      password: getEnv('dbPass', { dataSource, preAggregations }),
      ssl: this.getSslOptions(dataSource, preAggregations),
      ...config
    });
    this.pool.on('error', (err) => {
      console.log(`Unexpected error on idle client: ${err.stack || err}`);
    });
    this.config = <Partial<Config>>{
      ...this.getInitialConfiguration(),
      ...config,
    };
  }

  private getInitialConfiguration(): Partial<QuestDriverConfiguration> {
    return {
      readOnly: true,
    };
  }

  public async testConnection(): Promise<void> {
    await this.pool.query('SELECT $1 AS number', ['1']);
  }

  private mapFields(fields: FieldDef[]) {
    return fields.map((f) => {
      let questType;
      if (f.dataTypeID in NativeTypeToQuestType) {
        questType = NativeTypeToQuestType[f.dataTypeID].toLowerCase();
      }
      if (!questType) {
        throw new Error(
          `Unable to detect type for field "${f.name}" with dataTypeID: ${f.dataTypeID}`
        );
      }

      return ({
        name: f.name,
        type: this.toGenericType(questType)
      });
    });
  }

  public async query<R = unknown>(query: string, values: unknown[], _options?: QueryOptions): Promise<R[]> {
    const result = await this.queryResponse(query, values);
    return result.rows;
  }

  private async queryResponse(query: string, values: unknown[]) {
    const conn = await this.pool.connect();

    try {
      const res = await conn.query({
        text: query,
        values: values || [],
        types: {
          getTypeParser: this.getTypeParser,
        },
      });
      return res;
    } finally {
      conn.release();
    }
  }

  private getTypeParser(dataTypeID: TypeId, format: TypeFormat | undefined) {
    const isTimestamp = timestampDataTypes.includes(dataTypeID);
    if (isTimestamp) {
      return timestampTypeParser;
    }

    const parser = types.getTypeParser(dataTypeID, format);
    return (val: any) => parser(val);
  }

  public async downloadQueryResults(query: string, values: unknown[], _options: DownloadQueryResultsOptions) {
    const res = await this.queryResponse(query, values);
    return {
      rows: res.rows,
      types: this.mapFields(res.fields),
    };
  }

  public async createSchemaIfNotExists(_schemaName: string): Promise<any> {
    // no-op as there are no schemas in QuestDB
  }

  public async tablesSchema(): Promise<DatabaseStructure> {
    const tables = await this.getTablesQuery('');

    // QuestDB doesn't have a notion of schema/logical database while the driver
    // has to return a `{ 'schema_name': { 'table1': {...} } }` object. So, we use
    // empty schema name ('') as a workaround to avoid the schema prefix
    // ('schema_name.') being used for table names in the generated queries.
    const metadata: DatabaseStructure = { '': {} };

    // eslint-disable-next-line camelcase
    await Promise.all(tables.map(async ({ table_name: tableName }) => {
      if (tableName === undefined) {
        return;
      }
      // Skip system tables.
      if (tableName.startsWith('sys.')) {
        return;
      }
      const columns = await this.tableColumnTypes(tableName);
      metadata[''][tableName] = columns;
    }));

    return metadata;
  }

  // eslint-disable-next-line camelcase
  public async getTablesQuery(_schemaName: string): Promise<({ table_name?: string, TABLE_NAME?: string })[]> {
    return this.query('SHOW TABLES', []);
  }

  public async tableColumnTypes(table: string): Promise<TableStructure> {
    const response: any[] = await this.query(`SHOW COLUMNS FROM ${escapeStringLiteral(table)}`, []);

    return response.map((row) => ({ name: row.column, type: this.toGenericType(row.type) }));
  }

  public async uploadTableWithIndexes(
    table: string,
    columns: TableStructure,
    tableData: DownloadTableMemoryData,
    indexesSql: IndexesSQL
  ) {
    if (!tableData.rows) {
      throw new Error(`${this.constructor} driver supports only rows upload`);
    }

    await this.createTable(table, columns);

    try {
      for (let i = 0; i < tableData.rows.length; i++) {
        await this.query(
          `INSERT INTO ${escapeStringLiteral(table)}
        (${columns.map(c => this.quoteIdentifier(c.name)).join(', ')})
        VALUES (${columns.map((c, paramIndex) => this.param(paramIndex)).join(', ')})`,
          columns.map(c => this.toColumnValue(tableData.rows[i][c.name] as string, c.type))
        );
      }
      // Make sure to commit the data to make it visible for later queries.
      await this.query('COMMIT', []);

      for (let i = 0; i < indexesSql.length; i++) {
        const [query, params] = indexesSql[i].sql;
        await this.query(query, params);
      }
    } catch (e) {
      await this.dropTable(table);
      throw e;
    }
  }

  public readOnly() {
    return !!this.config.readOnly;
  }

  public release() {
    return this.pool.end();
  }

  public param(paramIndex: number) {
    return `$${paramIndex + 1}`;
  }
}
