// Oracledb Client
// -------
const _ = require('lodash');
const inherits = require('inherits');
const QueryCompiler = require('./query/compiler');
const ColumnCompiler = require('./schema/columncompiler');
const { BlobHelper, ReturningHelper, isConnectionError } = require('./utils');
const Bluebird = require('bluebird');
const stream = require('stream');
const { promisify } = require('util');
const Transaction = require('./transaction');
const Client_Oracle = require('../oracle');
const Oracle_Formatter = require('../oracle/formatter');

function Client_Oracledb() {
  Client_Oracle.apply(this, arguments);
  // Node.js only have 4 background threads by default, oracledb needs one by connection
  if (this.driver) {
    process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || 1;
    process.env.UV_THREADPOOL_SIZE =
      parseInt(process.env.UV_THREADPOOL_SIZE) + this.driver.poolMax;
  }
}
inherits(Client_Oracledb, Client_Oracle);

Client_Oracledb.prototype.driverName = 'oracledb';

Client_Oracledb.prototype._driver = function() {
  const client = this;
  const oracledb = require('oracledb');
  client.fetchAsString = [];
  if (this.config.fetchAsString && _.isArray(this.config.fetchAsString)) {
    this.config.fetchAsString.forEach(function(type) {
      if (!_.isString(type)) return;
      type = type.toUpperCase();
      if (oracledb[type]) {
        if (type !== 'NUMBER' && type !== 'DATE' && type !== 'CLOB') {
          this.logger.warn(
            'Only "date", "number" and "clob" are supported for fetchAsString'
          );
        }
        client.fetchAsString.push(oracledb[type]);
      }
    });
  }
  return oracledb;
};

Client_Oracledb.prototype.queryCompiler = function() {
  return new QueryCompiler(this, ...arguments);
};
Client_Oracledb.prototype.columnCompiler = function() {
  return new ColumnCompiler(this, ...arguments);
};
Client_Oracledb.prototype.formatter = function() {
  return new Oracledb_Formatter(this, ...arguments);
};
Client_Oracledb.prototype.transaction = function() {
  return new Transaction(this, ...arguments);
};

Client_Oracledb.prototype.prepBindings = function(bindings) {
  return _.map(bindings, (value) => {
    if (value instanceof BlobHelper && this.driver) {
      return { type: this.driver.BLOB, dir: this.driver.BIND_OUT };
      // Returning helper always use ROWID as string
    } else if (value instanceof ReturningHelper && this.driver) {
      return { type: this.driver.STRING, dir: this.driver.BIND_OUT };
    } else if (typeof value === 'boolean') {
      return value ? 1 : 0;
    }
    return value;
  });
};

// Get a raw connection, called by the `pool` whenever a new
// connection needs to be added to the pool.
Client_Oracledb.prototype.acquireRawConnection = function() {
  const client = this;
  const asyncConnection = new Bluebird(function(resolver, rejecter) {
    // If external authentication don't have to worry about username/password and
    // if not need to set the username and password
    const oracleDbConfig = client.connectionSettings.externalAuth
      ? { externalAuth: client.connectionSettings.externalAuth }
      : {
          user: client.connectionSettings.user,
          password: client.connectionSettings.password,
        };

    // In the case of external authentication connection string will be given
    oracleDbConfig.connectString =
      client.connectionSettings.connectString ||
      client.connectionSettings.host + '/' + client.connectionSettings.database;

    if (client.connectionSettings.prefetchRowCount) {
      oracleDbConfig.prefetchRows = client.connectionSettings.prefetchRowCount;
    }

    if (!_.isUndefined(client.connectionSettings.stmtCacheSize)) {
      oracleDbConfig.stmtCacheSize = client.connectionSettings.stmtCacheSize;
    }

    client.driver.fetchAsString = client.fetchAsString;

    client.driver.getConnection(oracleDbConfig, function(err, connection) {
      if (err) {
        return rejecter(err);
      }
      connection.commitAsync = function() {
        return new Bluebird((commitResolve, commitReject) => {
          if (connection.isTransaction) {
            return commitResolve();
          }
          this.commit(function(err) {
            if (err) {
              return commitReject(err);
            }
            commitResolve();
          });
        });
      };
      connection.rollbackAsync = function() {
        return new Bluebird((rollbackResolve, rollbackReject) => {
          this.rollback(function(err) {
            if (err) {
              return rollbackReject(err);
            }
            rollbackResolve();
          });
        });
      };
      const fetchAsync = promisify(function(sql, bindParams, options, cb) {
        options = options || {};
        options.outFormat =
          client.driver.OUT_FORMAT_OBJECT || client.driver.OBJECT;
        if (!options.outFormat) {
          throw new Error('not found oracledb.outFormat constants');
        }
        if (options.resultSet) {
          connection.execute(sql, bindParams || [], options, function(
            err,
            result
          ) {
            if (err) {
              if (isConnectionError(err)) {
                connection.close().catch(function(err) {});
                connection.__knex__disposed = err;
              }
              return cb(err);
            }
            const fetchResult = { rows: [], resultSet: result.resultSet };
            const numRows = 100;
            const fetchRowsFromRS = function(connection, resultSet, numRows) {
              resultSet.getRows(numRows, function(err, rows) {
                if (err) {
                  if (isConnectionError(err)) {
                    connection.close().catch(function(err) {});
                    connection.__knex__disposed = err;
                  }
                  resultSet.close(function() {
                    return cb(err);
                  });
                } else if (rows.length === 0) {
                  return cb(null, fetchResult);
                } else if (rows.length > 0) {
                  if (rows.length === numRows) {
                    fetchResult.rows = fetchResult.rows.concat(rows);
                    fetchRowsFromRS(connection, resultSet, numRows);
                  } else {
                    fetchResult.rows = fetchResult.rows.concat(rows);
                    return cb(null, fetchResult);
                  }
                }
              });
            };
            fetchRowsFromRS(connection, result.resultSet, numRows);
          });
        } else {
          connection.execute(sql, bindParams || [], options, function(
            err,
            result
          ) {
            if (err) {
              // dispose the connection on connection error
              if (isConnectionError(err)) {
                connection.close().catch(function(err) {});
                connection.__knex__disposed = err;
              }
              return cb(err);
            }

            return cb(null, result);
          });
        }
      });
      connection.executeAsync = function(sql, bindParams, options) {
        // Read all lob
        return fetchAsync(sql, bindParams, options).then(async (results) => {
          const closeResultSet = () => {
            return results.resultSet
              ? promisify(results.resultSet.close).call(results.resultSet)
              : Promise.resolve();
          };

          // Collect LOBs to read
          const lobs = [];
          if (results.rows) {
            if (Array.isArray(results.rows)) {
              for (let i = 0; i < results.rows.length; i++) {
                // Iterate through the rows
                const row = results.rows[i];
                for (const column in row) {
                  if (row[column] instanceof stream.Readable) {
                    lobs.push({ index: i, key: column, stream: row[column] });
                  }
                }
              }
            }
          }

          try {
            for (const lob of lobs) {
              // todo should be fetchAsString/fetchAsBuffer polyfill only
              results.rows[lob.index][lob.key] = await lobProcessing(
                lob.stream
              );
            }
          } catch (e) {
            await closeResultSet().catch(() => {});

            throw e;
          }

          await closeResultSet();

          return results;
        });
      };
      resolver(connection);
    });
  });
  return asyncConnection;
};

// Used to explicitly close a connection, called internally by the pool
// when a connection times out or the pool is shutdown.
Client_Oracledb.prototype.destroyRawConnection = function(connection) {
  return connection.release();
};

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

  const options = { autoCommit: false };
  if (obj.method === 'select') {
    options.resultSet = true;
  }
  return Bluebird.resolve(
    connection.executeAsync(obj.sql, obj.bindings, options)
  ).then(async function(response) {
    // Flatten outBinds
    let outBinds = _.flatten(response.outBinds);
    obj.response = response.rows || [];
    obj.rowsAffected = response.rows
      ? response.rows.rowsAffected
      : response.rowsAffected;

    //added for outBind parameter
    if (obj.method === 'raw' && outBinds.length > 0) {
      return {
        response: outBinds,
      };
    }

    if (obj.method === 'update') {
      const modifiedRowsCount = obj.rowsAffected.length || obj.rowsAffected;
      const updatedObjOutBinding = [];
      const updatedOutBinds = [];
      const updateOutBinds = (i) =>
        function(value, index) {
          const OutBindsOffset = index * modifiedRowsCount;
          updatedOutBinds.push(outBinds[i + OutBindsOffset]);
        };

      for (let i = 0; i < modifiedRowsCount; i++) {
        updatedObjOutBinding.push(obj.outBinding[0]);
        _.each(obj.outBinding[0], updateOutBinds(i));
      }
      outBinds = updatedOutBinds;
      obj.outBinding = updatedObjOutBinding;
    }

    if (!obj.returning && outBinds.length === 0) {
      await connection.commitAsync();
      return obj;
    }
    const rowIds = [];
    let offset = 0;

    for (let line = 0; line < obj.outBinding.length; line++) {
      const ret = obj.outBinding[line];

      offset =
        offset +
        (obj.outBinding[line - 1] ? obj.outBinding[line - 1].length : 0);

      for (let index = 0; index < ret.length; index++) {
        const out = ret[index];

        await new Promise(function(bindResolver, bindRejecter) {
          if (out instanceof BlobHelper) {
            const blob = outBinds[index + offset];
            if (out.returning) {
              obj.response[line] = obj.response[line] || {};
              obj.response[line][out.columnName] = out.value;
            }
            blob.on('error', function(err) {
              bindRejecter(err);
            });
            blob.on('finish', function() {
              bindResolver();
            });
            blob.write(out.value);
            blob.end();
          } else if (obj.outBinding[line][index] === 'ROWID') {
            rowIds.push(outBinds[index + offset]);
            bindResolver();
          } else {
            obj.response[line] = obj.response[line] || {};
            obj.response[line][out] = outBinds[index + offset];
            bindResolver();
          }
        });
      }
    }
    return connection.commitAsync().then(function() {
      if (obj.returningSql) {
        return connection
          .executeAsync(obj.returningSql(), rowIds, { resultSet: true })
          .then(function(response) {
            obj.response = response.rows;
            return obj;
          });
      }
      return obj;
    });
  });
};

/**
 * @param stream
 * @param {'string' | 'buffer'} type
 */
function readStream(stream, type) {
  return new Promise((resolve, reject) => {
    let data = type === 'string' ? '' : Buffer.alloc(0);

    stream.on('error', function(err) {
      reject(err);
    });
    stream.on('data', function(chunk) {
      if (type === 'string') {
        data += chunk;
      } else {
        data = Buffer.concat([data, chunk]);
      }
    });
    stream.on('end', function() {
      resolve(data);
    });
  });
}

// Process the response as returned from the query.
Client_Oracledb.prototype.processResponse = function(obj, runner) {
  let response = obj.response;
  const method = obj.method;
  if (obj.output) {
    return obj.output.call(runner, response);
  }
  switch (method) {
    case 'select':
    case 'pluck':
    case 'first':
      if (obj.method === 'pluck') {
        response = _.map(response, obj.pluck);
      }
      return obj.method === 'first' ? response[0] : response;
    case 'insert':
    case 'del':
    case 'update':
    case 'counter':
      if (obj.returning && !_.isEmpty(obj.returning)) {
        if (obj.returning.length === 1 && obj.returning[0] !== '*') {
          return _.flatten(_.map(response, _.values));
        }
        return response;
      } else if (!_.isUndefined(obj.rowsAffected)) {
        return obj.rowsAffected;
      } else {
        return 1;
      }
    default:
      return response;
  }
};

const lobProcessing = function(stream) {
  const oracledb = require('oracledb');

  /**
   * @type 'string' | 'buffer'
   */
  let type;

  if (stream.type) {
    // v1.2-v4
    if (stream.type === oracledb.BLOB) {
      type = 'buffer';
    } else if (stream.type === oracledb.CLOB) {
      type = 'string';
    }
  } else if (stream.iLob) {
    // v1
    if (stream.iLob.type === oracledb.CLOB) {
      type = 'string';
    } else if (stream.iLob.type === oracledb.BLOB) {
      type = 'buffer';
    }
  } else {
    throw new Error('Unrecognized oracledb lob stream type');
  }
  if (type === 'string') {
    stream.setEncoding('utf-8');
  }
  return readStream(stream, type);
};

class Oracledb_Formatter extends Oracle_Formatter {
  // Checks whether a value is a function... if it is, we compile it
  // otherwise we check whether it's a raw
  parameter(value) {
    if (typeof value === 'function') {
      return this.outputQuery(this.compileCallback(value), true);
    } else if (value instanceof BlobHelper) {
      return 'EMPTY_BLOB()';
    }
    return this.unwrapRaw(value, true) || '?';
  }
}

module.exports = Client_Oracledb;
