const clone = require('lodash/clone');
const each = require('lodash/each');
const isEmpty = require('lodash/isEmpty');
const isPlainObject = require('lodash/isPlainObject');
const Oracle_Compiler = require('../../oracle/query/oracle-querycompiler');
const ReturningHelper = require('../utils').ReturningHelper;
const BlobHelper = require('../utils').BlobHelper;
const { isString } = require('../../../util/is');
const {
  columnize: columnize_,
} = require('../../../formatter/wrappingFormatter');

class Oracledb_Compiler extends Oracle_Compiler {
  // Compiles an "insert" query, allowing for multiple
  // inserts using a single query statement.
  insert() {
    const self = this;
    const outBindPrep = this._prepOutbindings(
      this.single.insert,
      this.single.returning
    );
    const outBinding = outBindPrep.outBinding;
    const returning = outBindPrep.returning;
    const insertValues = outBindPrep.values;

    if (
      Array.isArray(insertValues) &&
      insertValues.length === 1 &&
      isEmpty(insertValues[0])
    ) {
      const returningFragment = this.single.returning
        ? ' (' + this.formatter.wrap(this.single.returning) + ')'
        : '';

      return this._addReturningToSqlAndConvert(
        'insert into ' +
          this.tableName +
          returningFragment +
          ' values (default)',
        outBinding[0],
        this.tableName,
        returning
      );
    }

    if (
      isEmpty(this.single.insert) &&
      typeof this.single.insert !== 'function'
    ) {
      return '';
    }

    const insertData = this._prepInsert(insertValues);

    const sql = {};

    if (isString(insertData)) {
      return this._addReturningToSqlAndConvert(
        'insert into ' + this.tableName + ' ' + insertData,
        outBinding[0],
        this.tableName,
        returning
      );
    }

    if (insertData.values.length === 1) {
      return this._addReturningToSqlAndConvert(
        'insert into ' +
          this.tableName +
          ' (' +
          this.formatter.columnize(insertData.columns) +
          ') values (' +
          this.client.parameterize(
            insertData.values[0],
            undefined,
            this.builder,
            this.bindingsHolder
          ) +
          ')',
        outBinding[0],
        this.tableName,
        returning
      );
    }

    const insertDefaultsOnly = insertData.columns.length === 0;
    sql.returning = returning;
    sql.sql =
      'begin ' +
      insertData.values
        .map(function (value, index) {
          const parameterizedValues = !insertDefaultsOnly
            ? self.client.parameterize(
                value,
                self.client.valueForUndefined,
                self.builder,
                self.bindingsHolder
              )
            : '';
          let subSql = 'insert into ' + self.tableName;

          if (insertDefaultsOnly) {
            // No columns given so only the default value
            subSql +=
              ' (' +
              self.formatter.wrap(self.single.returning) +
              ') values (default)';
          } else {
            subSql +=
              ' (' +
              self.formatter.columnize(insertData.columns) +
              ') values (' +
              parameterizedValues +
              ')';
          }

          let returningClause = '';
          let intoClause = '';
          // ToDo review if this code is still needed or could be dropped
          // eslint-disable-next-line no-unused-vars
          let usingClause = '';
          let outClause = '';

          each(value, function (val) {
            if (!(val instanceof BlobHelper)) {
              usingClause += ' ?,';
            }
          });
          // eslint-disable-next-line no-unused-vars
          usingClause = usingClause.slice(0, -1);

          // Build returning and into clauses
          outBinding[index].forEach(function (ret) {
            const columnName = ret.columnName || ret;
            returningClause += self.formatter.wrap(columnName) + ',';
            intoClause += ' ?,';
            outClause += ' out ?,';

            // Add Helpers to bindings
            if (ret instanceof BlobHelper) {
              return self.formatter.bindings.push(ret);
            }
            self.formatter.bindings.push(new ReturningHelper(columnName));
          });

          // Strip last comma
          returningClause = returningClause.slice(0, -1);
          intoClause = intoClause.slice(0, -1);
          outClause = outClause.slice(0, -1);

          if (returningClause && intoClause) {
            subSql += ' returning ' + returningClause + ' into' + intoClause;
          }

          // Pre bind position because subSql is an execute immediate parameter
          // later position binding will only convert the ? params
          subSql = self.formatter.client.positionBindings(subSql);
          const parameterizedValuesWithoutDefaultAndBlob = parameterizedValues
            .replace(/DEFAULT, /g, '')
            .replace(/, DEFAULT/g, '')
            .replace('EMPTY_BLOB(), ', '')
            .replace(', EMPTY_BLOB()', '');
          return (
            "execute immediate '" +
            subSql.replace(/'/g, "''") +
            (parameterizedValuesWithoutDefaultAndBlob || value
              ? "' using "
              : '') +
            parameterizedValuesWithoutDefaultAndBlob +
            (parameterizedValuesWithoutDefaultAndBlob && outClause ? ',' : '') +
            outClause +
            ';'
          );
        })
        .join(' ') +
      'end;';

    sql.outBinding = outBinding;
    if (returning[0] === '*') {
      // Generate select statement with special order by
      // to keep the order because 'in (..)' may change the order
      sql.returningSql = function () {
        return (
          'select * from ' +
          self.tableName +
          ' where ROWID in (' +
          this.outBinding
            .map(function (v, i) {
              return ':' + (i + 1);
            })
            .join(', ') +
          ')' +
          ' order by case ROWID ' +
          this.outBinding
            .map(function (v, i) {
              return 'when CHARTOROWID(:' + (i + 1) + ') then ' + i;
            })
            .join(' ') +
          ' end'
        );
      };
    }

    return sql;
  }

  with() {
    // WITH RECURSIVE is a syntax error in Oracle SQL.
    // So mark all statements as non-recursive, generate the SQL, then restore.
    // This approach ensures any changes in base class with() get propagated here.
    const undoList = [];
    if (this.grouped.with) {
      for (const stmt of this.grouped.with) {
        if (stmt.recursive) {
          undoList.push(stmt);
          stmt.recursive = false;
        }
      }
    }

    const result = super.with();

    // Restore the recursive markings, in case this same query gets cloned and passed to other drivers.
    for (const stmt of undoList) {
      stmt.recursive = true;
    }
    return result;
  }

  _addReturningToSqlAndConvert(sql, outBinding, tableName, returning) {
    const self = this;
    const res = {
      sql: sql,
    };

    if (!outBinding) {
      return res;
    }
    const returningValues = Array.isArray(outBinding)
      ? outBinding
      : [outBinding];
    let returningClause = '';
    let intoClause = '';
    // Build returning and into clauses
    returningValues.forEach(function (ret) {
      const columnName = ret.columnName || ret;
      returningClause += self.formatter.wrap(columnName) + ',';
      intoClause += '?,';

      // Add Helpers to bindings
      if (ret instanceof BlobHelper) {
        return self.formatter.bindings.push(ret);
      }
      self.formatter.bindings.push(new ReturningHelper(columnName));
    });
    res.sql = sql;

    // Strip last comma
    returningClause = returningClause.slice(0, -1);
    intoClause = intoClause.slice(0, -1);
    if (returningClause && intoClause) {
      res.sql += ' returning ' + returningClause + ' into ' + intoClause;
    }
    res.outBinding = [outBinding];
    if (returning[0] === '*') {
      res.returningSql = function () {
        return 'select * from ' + self.tableName + ' where ROWID = :1';
      };
    }
    res.returning = returning;

    return res;
  }

  _prepOutbindings(paramValues, paramReturning) {
    const result = {};
    let params = paramValues || [];
    let returning = paramReturning || [];
    if (!Array.isArray(params) && isPlainObject(paramValues)) {
      params = [params];
    }
    // Always wrap returning argument in array
    if (returning && !Array.isArray(returning)) {
      returning = [returning];
    }

    const outBinding = [];
    // Handle Buffer value as Blob
    each(params, function (values, index) {
      if (returning[0] === '*') {
        outBinding[index] = ['ROWID'];
      } else {
        outBinding[index] = clone(returning);
      }
      each(values, function (value, key) {
        if (value instanceof Buffer) {
          values[key] = new BlobHelper(key, value);

          // Delete blob duplicate in returning
          const blobIndex = outBinding[index].indexOf(key);
          if (blobIndex >= 0) {
            outBinding[index].splice(blobIndex, 1);
            values[key].returning = true;
          }
          outBinding[index].push(values[key]);
        }
        if (value === undefined) {
          delete params[index][key];
        }
      });
    });
    result.returning = returning;
    result.outBinding = outBinding;
    result.values = params;
    return result;
  }

  _groupOrder(item, type) {
    return super._groupOrderNulls(item, type);
  }

  update() {
    const self = this;
    const sql = {};
    const outBindPrep = this._prepOutbindings(
      this.single.update || this.single.counter,
      this.single.returning
    );
    const outBinding = outBindPrep.outBinding;
    const returning = outBindPrep.returning;

    const updates = this._prepUpdate(this.single.update);
    const where = this.where();

    let returningClause = '';
    let intoClause = '';

    if (isEmpty(updates) && typeof this.single.update !== 'function') {
      return '';
    }

    // Build returning and into clauses
    outBinding.forEach(function (out) {
      out.forEach(function (ret) {
        const columnName = ret.columnName || ret;
        returningClause += self.formatter.wrap(columnName) + ',';
        intoClause += ' ?,';

        // Add Helpers to bindings
        if (ret instanceof BlobHelper) {
          return self.formatter.bindings.push(ret);
        }
        self.formatter.bindings.push(new ReturningHelper(columnName));
      });
    });
    // Strip last comma
    returningClause = returningClause.slice(0, -1);
    intoClause = intoClause.slice(0, -1);

    sql.outBinding = outBinding;
    sql.returning = returning;
    sql.sql =
      'update ' +
      this.tableName +
      ' set ' +
      updates.join(', ') +
      (where ? ' ' + where : '');
    if (outBinding.length && !isEmpty(outBinding[0])) {
      sql.sql += ' returning ' + returningClause + ' into' + intoClause;
    }
    if (returning[0] === '*') {
      sql.returningSql = function () {
        let sql = 'select * from ' + self.tableName;
        const modifiedRowsCount = this.rowsAffected.length || this.rowsAffected;
        let returningSqlIn = ' where ROWID in (';
        let returningSqlOrderBy = ') order by case ROWID ';

        // Needs special order by because in(...) change result order
        for (let i = 0; i < modifiedRowsCount; i++) {
          if (this.returning[0] === '*') {
            returningSqlIn += ':' + (i + 1) + ', ';
            returningSqlOrderBy +=
              'when CHARTOROWID(:' + (i + 1) + ') then ' + i + ' ';
          }
        }
        if (this.returning[0] === '*') {
          this.returning = this.returning.slice(0, -1);
          returningSqlIn = returningSqlIn.slice(0, -2);
          returningSqlOrderBy = returningSqlOrderBy.slice(0, -1);
        }
        return (sql += returningSqlIn + returningSqlOrderBy + ' end');
      };
    }

    return sql;
  }

  _jsonPathWrap(extraction) {
    return `'${extraction.path || extraction[1]}'`;
  }

  // Json functions
  jsonExtract(params) {
    return this._jsonExtract(
      params.singleValue ? 'json_value' : 'json_query',
      params
    );
  }

  jsonSet(params) {
    return `json_transform(${columnize_(
      params.column,
      this.builder,
      this.client,
      this.bindingsHolder
    )}, set ${this.client.parameter(
      params.path,
      this.builder,
      this.bindingsHolder
    )} = ${this.client.parameter(
      params.value,
      this.builder,
      this.bindingsHolder
    )})`;
  }

  jsonInsert(params) {
    return `json_transform(${columnize_(
      params.column,
      this.builder,
      this.client,
      this.bindingsHolder
    )}, insert ${this.client.parameter(
      params.path,
      this.builder,
      this.bindingsHolder
    )} = ${this.client.parameter(
      params.value,
      this.builder,
      this.bindingsHolder
    )})`;
  }

  jsonRemove(params) {
    const jsonCol = `json_transform(${columnize_(
      params.column,
      this.builder,
      this.client,
      this.bindingsHolder
    )}, remove ${this.client.parameter(
      params.path,
      this.builder,
      this.bindingsHolder
    )})`;
    return params.alias
      ? this.client.alias(jsonCol, this.formatter.wrap(params.alias))
      : jsonCol;
  }

  whereJsonPath(statement) {
    return this._whereJsonPath('json_value', statement);
  }

  whereJsonSupersetOf(statement) {
    throw new Error(
      'Json superset where clause not actually supported by Oracle'
    );
  }

  whereJsonSubsetOf(statement) {
    throw new Error(
      'Json subset where clause not actually supported by Oracle'
    );
  }

  onJsonPathEquals(clause) {
    return this._onJsonPathEquals('json_value', clause);
  }
}

module.exports = Oracledb_Compiler;
