// MSSQL Client
// -------
const map = require('lodash/map');
const isNil = require('lodash/isNil');

const Client = require('../../client');
const MSSQL_Formatter = require('./mssql-formatter');
const Transaction = require('./transaction');
const QueryCompiler = require('./query/mssql-querycompiler');
const SchemaCompiler = require('./schema/mssql-compiler');
const TableCompiler = require('./schema/mssql-tablecompiler');
const ViewCompiler = require('./schema/mssql-viewcompiler');
const ColumnCompiler = require('./schema/mssql-columncompiler');
const QueryBuilder = require('../../query/querybuilder');
const { setHiddenProperty } = require('../../util/security');

const debug = require('debug')('knex:mssql');

const SQL_INT4 = { MIN: -2147483648, MAX: 2147483647 };
const SQL_BIGINT_SAFE = { MIN: -9007199254740991, MAX: 9007199254740991 };

// Always initialize with the "QueryBuilder" and "QueryCompiler" objects, which
// extend the base 'lib/query/builder' and 'lib/query/compiler', respectively.
class Client_MSSQL extends Client {
  constructor(config = {}) {
    super(config);
  }

  /**
   * @param {import('knex').Config} options
   */
  _generateConnection() {
    const settings = this.connectionSettings;
    settings.options = settings.options || {};

    /** @type {import('tedious').ConnectionConfig} */
    const cfg = {
      authentication: {
        type: settings.type || 'default',
        options: {
          userName: settings.userName || settings.user,
          password: settings.password,
          domain: settings.domain,
          token: settings.token,
          clientId: settings.clientId,
          clientSecret: settings.clientSecret,
          tenantId: settings.tenantId,
          msiEndpoint: settings.msiEndpoint,
        },
      },
      server: settings.server || settings.host,
      options: {
        database: settings.database,
        encrypt: settings.encrypt || false,
        port: settings.port || 1433,
        connectTimeout: settings.connectionTimeout || settings.timeout || 15000,
        requestTimeout: !isNil(settings.requestTimeout)
          ? settings.requestTimeout
          : 15000,
        rowCollectionOnDone: false,
        rowCollectionOnRequestCompletion: false,
        useColumnNames: false,
        tdsVersion: settings.options.tdsVersion || '7_4',
        appName: settings.options.appName || 'knex',
        trustServerCertificate: false,
        ...settings.options,
      },
    };

    if (cfg.authentication.options.password) {
      setHiddenProperty(cfg.authentication.options);
    }

    // tedious always connect via tcp when port is specified
    if (cfg.options.instanceName) delete cfg.options.port;

    if (isNaN(cfg.options.requestTimeout)) cfg.options.requestTimeout = 15000;
    if (cfg.options.requestTimeout === Infinity) cfg.options.requestTimeout = 0;
    if (cfg.options.requestTimeout < 0) cfg.options.requestTimeout = 0;

    if (settings.debug) {
      cfg.options.debug = {
        packet: true,
        token: true,
        data: true,
        payload: true,
      };
    }

    return cfg;
  }

  _driver() {
    const tds = require('tedious');

    return tds;
  }

  formatter() {
    return new MSSQL_Formatter(this, ...arguments);
  }

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

  queryCompiler() {
    return new QueryCompiler(this, ...arguments);
  }

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

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

  viewCompiler() {
    return new ViewCompiler(this, ...arguments);
  }
  queryBuilder() {
    const b = new QueryBuilder(this);
    return b;
  }

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

  wrapIdentifierImpl(value) {
    if (value === '*') {
      return '*';
    }

    return `[${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) => {
      debug('connection::connection new connection requested');
      const Driver = this._driver();
      const settings = Object.assign({}, this._generateConnection());

      const connection = new Driver.Connection(settings);

      connection.connect((err) => {
        if (err) {
          debug('connection::connect error: %s', err.message);
          return rejecter(err);
        }

        debug('connection::connect connected to server');

        connection.connected = true;
        connection.on('error', (e) => {
          debug('connection::error message=%s', e.message);
          connection.__knex__disposed = e;
          connection.connected = false;
        });

        connection.once('end', () => {
          connection.connected = false;
          connection.__knex__disposed = 'Connection to server was terminated.';
          debug('connection::end connection ended.');
        });

        return resolver(connection);
      });
    });
  }

  validateConnection(connection) {
    return connection && connection.connected;
  }

  // Used to explicitly close a connection, called internally by the pool
  // when a connection times out or the pool is shutdown.
  destroyRawConnection(connection) {
    debug('connection::destroy');

    return new Promise((resolve) => {
      connection.once('end', () => {
        resolve();
      });

      connection.close();
    });
  }

  // Position the bindings for the query.
  positionBindings(sql) {
    let questionCount = -1;
    return sql.replace(/\\?\?/g, (match) => {
      if (match === '\\?') {
        return '?';
      }

      questionCount += 1;
      return `@p${questionCount}`;
    });
  }

  _chomp(connection) {
    if (connection.state.name === 'LoggedIn') {
      const nextRequest = this.requestQueue.pop();
      if (nextRequest) {
        debug(
          'connection::query executing query, %d more in queue',
          this.requestQueue.length
        );

        connection.execSql(nextRequest);
      }
    }
  }

  _enqueueRequest(request, connection) {
    this.requestQueue.push(request);
    this._chomp(connection);
  }

  _makeRequest(query, callback) {
    const Driver = this._driver();
    const sql = typeof query === 'string' ? query : query.sql;
    let rowCount = 0;

    if (!sql) throw new Error('The query is empty');

    debug('request::request sql=%s', sql);

    const request = new Driver.Request(sql, (err, remoteRowCount) => {
      if (err) {
        debug('request::error message=%s', err.message);
        return callback(err);
      }

      rowCount = remoteRowCount;
      debug('request::callback rowCount=%d', rowCount);
    });

    request.on('prepared', () => {
      debug('request %s::request prepared', this.id);
    });

    request.on('done', (rowCount, more) => {
      debug('request::done rowCount=%d more=%s', rowCount, more);
    });

    request.on('doneProc', (rowCount, more) => {
      debug(
        'request::doneProc id=%s rowCount=%d more=%s',
        request.id,
        rowCount,
        more
      );
    });

    request.on('doneInProc', (rowCount, more) => {
      debug(
        'request::doneInProc id=%s rowCount=%d more=%s',
        request.id,
        rowCount,
        more
      );
    });

    request.once('requestCompleted', () => {
      debug('request::completed id=%s', request.id);
      return callback(null, rowCount);
    });

    request.on('error', (err) => {
      debug('request::error id=%s message=%s', request.id, err.message);
      return callback(err);
    });

    return request;
  }

  // Grab a connection, run the query via the MSSQL streaming interface,
  // and pass that through to the stream we've sent back to the client.
  _stream(connection, query, /** @type {NodeJS.ReadWriteStream} */ stream) {
    return new Promise((resolve, reject) => {
      const request = this._makeRequest(query, (err) => {
        if (err) {
          stream.emit('error', err);
          return reject(err);
        }

        resolve();
      });

      request.on('row', (row) => {
        stream.write(
          row.reduce((prev, curr) => {
            prev[curr.metadata.colName] = curr.value;

            return prev;
          }, {})
        );
      });
      request.on('error', (err) => {
        stream.emit('error', err);
        reject(err);
      });
      request.once('requestCompleted', () => {
        stream.end();
        resolve();
      });

      this._assignBindings(request, query.bindings);
      this._enqueueRequest(request, connection);
    });
  }

  _assignBindings(request, bindings) {
    if (Array.isArray(bindings)) {
      for (let i = 0; i < bindings.length; i++) {
        const binding = bindings[i];
        this._setReqInput(request, i, binding);
      }
    }
  }

  _scaleForBinding(binding) {
    if (binding % 1 === 0) {
      throw new Error(`The binding value ${binding} must be a decimal number.`);
    }

    return { scale: 10 };
  }

  _typeForBinding(binding) {
    const Driver = this._driver();

    if (
      this.connectionSettings.options &&
      this.connectionSettings.options.mapBinding
    ) {
      const result = this.connectionSettings.options.mapBinding(binding);
      if (result) {
        return [result.value, result.type];
      }
    }

    switch (typeof binding) {
      case 'string':
        return [binding, Driver.TYPES.NVarChar];
      case 'boolean':
        return [binding, Driver.TYPES.Bit];
      case 'number': {
        if (binding % 1 !== 0) {
          return [binding, Driver.TYPES.Float];
        }

        if (binding < SQL_INT4.MIN || binding > SQL_INT4.MAX) {
          if (binding < SQL_BIGINT_SAFE.MIN || binding > SQL_BIGINT_SAFE.MAX) {
            throw new Error(
              `Bigint must be safe integer or must be passed as string, saw ${binding}`
            );
          }

          return [binding, Driver.TYPES.BigInt];
        }

        return [binding, Driver.TYPES.Int];
      }
      default: {
        if (binding instanceof Date) {
          return [binding, Driver.TYPES.DateTime];
        }

        if (binding instanceof Buffer) {
          return [binding, Driver.TYPES.VarBinary];
        }

        return [binding, Driver.TYPES.NVarChar];
      }
    }
  }

  // Runs the query on the specified connection, providing the bindings
  // and any other necessary prep work.
  _query(connection, query) {
    return new Promise((resolve, reject) => {
      const rows = [];
      const request = this._makeRequest(query, (err, count) => {
        if (err) {
          return reject(err);
        }

        query.response = rows;

        process.nextTick(() => this._chomp(connection));

        resolve(query);
      });

      request.on('row', (row) => {
        debug('request::row');
        rows.push(row);
      });

      this._assignBindings(request, query.bindings);
      this._enqueueRequest(request, connection);
    });
  }

  // sets a request input parameter. Detects bigints and decimals and sets type appropriately.
  _setReqInput(req, i, inputBinding) {
    const [binding, tediousType] = this._typeForBinding(inputBinding);
    const bindingName = 'p'.concat(i);
    let options;

    if (typeof binding === 'number' && binding % 1 !== 0) {
      options = this._scaleForBinding(binding);
    }

    debug(
      'request::binding pos=%d type=%s value=%s',
      i,
      tediousType.name,
      binding
    );

    if (Buffer.isBuffer(binding)) {
      options = {
        length: 'max',
      };
    }

    req.addParameter(bindingName, tediousType, binding, options);
  }

  // Process the response as returned from the query.
  processResponse(query, runner) {
    if (query == null) return;
    let { response } = query;
    const { method } = query;

    if (query.output) {
      return query.output.call(runner, response);
    }

    response = response.map((row) =>
      row.reduce((columns, r) => {
        const colName = r.metadata.colName;

        if (columns[colName]) {
          if (!Array.isArray(columns[colName])) {
            columns[colName] = [columns[colName]];
          }

          columns[colName].push(r.value);
        } else {
          columns[colName] = r.value;
        }

        return columns;
      }, {})
    );

    if (query.output) return query.output.call(runner, response);
    switch (method) {
      case 'select':
        return response;
      case 'first':
        return response[0];
      case 'pluck':
        return map(response, query.pluck);
      case 'insert':
      case 'del':
      case 'update':
      case 'counter':
        if (query.returning) {
          if (query.returning === '@@rowcount') {
            return response[0][''];
          }
        }
        return response;
      default:
        return response;
    }
  }
}

Object.assign(Client_MSSQL.prototype, {
  requestQueue: [],

  dialect: 'mssql',

  driverName: 'mssql',
});

module.exports = Client_MSSQL;
