// MySQL Client
// -------
const defer = require('lodash/defer');
const map = require('lodash/map');
const { promisify } = require('util');
const Client = require('../../client');

const Transaction = require('./transaction');
const QueryBuilder = require('./query/mysql-querybuilder');
const QueryCompiler = require('./query/mysql-querycompiler');
const SchemaCompiler = require('./schema/mysql-compiler');
const TableCompiler = require('./schema/mysql-tablecompiler');
const ColumnCompiler = require('./schema/mysql-columncompiler');

const { makeEscape } = require('../../util/string');
const ViewCompiler = require('./schema/mysql-viewcompiler');
const ViewBuilder = require('./schema/mysql-viewbuilder');

// Always initialize with the "QueryBuilder" and "QueryCompiler"
// objects, which extend the base 'lib/query/builder' and
// 'lib/query/compiler', respectively.
class Client_MySQL extends Client {
  _driver() {
    return require('mysql');
  }

  queryBuilder() {
    return new QueryBuilder(this);
  }

  queryCompiler(builder, formatter) {
    return new QueryCompiler(this, builder, formatter);
  }

  schemaCompiler() {
    return new SchemaCompiler(this, ...arguments);
  }

  tableCompiler() {
    return new TableCompiler(this, ...arguments);
  }

  viewCompiler() {
    return new ViewCompiler(this, ...arguments);
  }

  viewBuilder() {
    return new ViewBuilder(this, ...arguments);
  }

  columnCompiler() {
    return new ColumnCompiler(this, ...arguments);
  }

  transaction() {
    return new Transaction(this, ...arguments);
  }

  wrapIdentifierImpl(value) {
    return value !== '*' ? `\`${value.replace(/`/g, '``')}\`` : '*';
  }

  // Get a raw connection, called by the `pool` whenever a new
  // connection needs to be added to the pool.
  acquireRawConnection() {
    return new Promise((resolver, rejecter) => {
      const connection = this.driver.createConnection(this.connectionSettings);
      connection.on('error', (err) => {
        connection.__knex__disposed = err;
      });
      connection.connect(async (err) => {
        if (err) {
          // if connection is rejected, remove listener that was registered above...
          connection.removeAllListeners();
          return rejecter(err);
        }
        try {
          await this.checkVersion(connection);
        } catch (versionError) {
          const suffix = versionError?.message
            ? ` (${versionError.message})`
            : '';
          this.logger.warn(
            `Knex: Unable to detect MySQL/MariaDB version${suffix}. Assuming latest version.`
          );
        }
        resolver(connection);
      });
    });
  }

  // Used to explicitly close a connection, called internally by the pool
  // when a connection times out or the pool is shutdown.
  async destroyRawConnection(connection) {
    try {
      const end = promisify((cb) => connection.end(cb));
      return await end();
    } catch (err) {
      connection.__knex__disposed = err;
    } finally {
      // see discussion https://github.com/knex/knex/pull/3483
      defer(() => connection.removeAllListeners());
    }
  }

  validateConnection(connection) {
    return (
      connection.state === 'connected' || connection.state === 'authenticated'
    );
  }

  // Grab a connection, run the query via the MySQL streaming interface,
  // and pass that through to the stream we've sent back to the client.
  _stream(connection, obj, stream, options) {
    if (!obj.sql) throw new Error('The query is empty');

    options = options || {};
    const queryOptions = Object.assign({ sql: obj.sql }, obj.options);
    return new Promise((resolver, rejecter) => {
      stream.on('error', rejecter);
      stream.on('end', resolver);
      const queryStream = connection
        .query(queryOptions, obj.bindings)
        .stream(options);

      queryStream.on('error', (err) => {
        rejecter(err);
        stream.emit('error', err);
      });

      queryStream.pipe(stream);
    });
  }

  // Runs the query on the specified connection, providing the bindings
  // and any other necessary prep work.
  _query(connection, obj) {
    if (!obj || typeof obj === 'string') obj = { sql: obj };
    if (!obj.sql) throw new Error('The query is empty');

    return new Promise(function (resolver, rejecter) {
      if (!obj.sql) {
        resolver();
        return;
      }
      const queryOptions = Object.assign({ sql: obj.sql }, obj.options);
      connection.query(
        queryOptions,
        obj.bindings,
        function (err, rows, fields) {
          if (err) return rejecter(err);
          obj.response = [rows, fields];
          resolver(obj);
        }
      );
    });
  }

  // Process the response as returned from the query.
  processResponse(obj, runner) {
    if (obj == null) return;
    const { response } = obj;
    const { method } = obj;
    const rows = response[0];
    const fields = response[1];
    if (obj.output) return obj.output.call(runner, rows, fields);
    switch (method) {
      case 'select':
        return rows;
      case 'first':
        return rows[0];
      case 'pluck':
        return map(rows, obj.pluck);
      case 'insert':
        return [rows.insertId];
      case 'del':
      case 'update':
      case 'counter':
        return rows.affectedRows;
      default:
        return response;
    }
  }

  async cancelQuery(connectionToKill) {
    const conn = await this.acquireRawConnection();
    try {
      return await this._wrappedCancelQueryCall(conn, connectionToKill);
    } finally {
      await this.destroyRawConnection(conn);
      if (conn.__knex__disposed) {
        this.logger.warn(`Connection Error: ${conn.__knex__disposed}`);
      }
    }
  }

  _wrappedCancelQueryCall(conn, connectionToKill) {
    return this._query(conn, {
      sql: 'KILL QUERY ?',
      bindings: [connectionToKill.threadId],
      options: {},
    });
  }

  async checkVersion(connection) {
    if (this._versionCheckPromise) {
      return this._versionCheckPromise;
    }

    this._versionCheckPromise = (async () => {
      if (this.version) {
        return this._resolveConfiguredVersion(this.version);
      }

      try {
        const query = promisify(connection.query).bind(connection);
        const rows = await query('select version() as version');
        const rawVersion = rows?.[0]?.version;

        if (!rawVersion) {
          return this._assumeLatestVersion({
            reason: 'Unable to retrieve MySQL/MariaDB version from server',
          });
        }

        return this._applyParsedVersion(rawVersion, { source: 'server' });
      } catch (err) {
        return this._assumeLatestVersion({
          reason: 'Unable to retrieve MySQL/MariaDB version from server',
          error: err,
        });
      }
    })();

    return this._versionCheckPromise;
  }

  _resolveConfiguredVersion(versionString) {
    if (this._isLatestVersionString(versionString)) {
      return this._assumeLatestVersion({
        reason: 'MySQL/MariaDB version set to latest in configuration',
        skipWarning: true,
      });
    }

    return this._applyParsedVersion(versionString, { source: 'config' });
  }

  _applyParsedVersion(versionString, { source }) {
    const { version, isMariaDB } = this._parseVersion(versionString);

    if (typeof isMariaDB !== 'undefined') {
      this.isMariaDB = isMariaDB;
    }

    if (!version) {
      const reason =
        source === 'config'
          ? `Invalid MySQL/MariaDB version "${versionString}" in configuration`
          : `Unable to parse MySQL/MariaDB version string "${versionString}"`;
      return this._assumeLatestVersion({ reason });
    }

    if (!this.version || source === 'server') {
      this.version = version;
    }

    return this.version;
  }

  _assumeLatestVersion({ reason, error, skipWarning } = {}) {
    if (!skipWarning) {
      const suffix = error && error.message ? ` (${error.message})` : '';
      this.logger.warn(`Knex: ${reason}${suffix}. Assuming latest version.`);
    }

    // Null signals "assume latest" so version gating stays permissive.
    this.version = null;
    return this.version;
  }

  _isLatestVersionString(versionString) {
    return (
      typeof versionString === 'string' &&
      versionString.toLowerCase() === 'latest'
    );
  }

  _parseVersion(versionString) {
    const isMariaDB = /mariadb/i.test(versionString);
    let versionMatch;

    if (isMariaDB) {
      const mariaMatch =
        versionString.match(/(\d+\.\d+\.\d+)(?=[^\d]*mariadb)/i) ||
        versionString.match(/(\d+\.\d+\.\d+)/);
      versionMatch = mariaMatch && mariaMatch[1];
    } else {
      const mysqlMatch = versionString.match(/(\d+\.\d+\.\d+)/);
      versionMatch = mysqlMatch && mysqlMatch[1];
    }

    return { version: versionMatch || null, isMariaDB };
  }
}

Object.assign(Client_MySQL.prototype, {
  dialect: 'mysql',

  driverName: 'mysql',

  _escapeBinding: makeEscape(),

  canCancelQuery: true,
});

module.exports = Client_MySQL;
