// MySQL Query Compiler
// ------
const assert = require('assert');
const identity = require('lodash/identity');
const isPlainObject = require('lodash/isPlainObject');
const isEmpty = require('lodash/isEmpty');
const QueryCompiler = require('../../../query/querycompiler');
const { wrapAsIdentifier } = require('../../../formatter/formatterUtils');
const {
  columnize: columnize_,
  wrap: wrap_,
} = require('../../../formatter/wrappingFormatter');

const isPlainObjectOrArray = (value) =>
  isPlainObject(value) || Array.isArray(value);

class QueryCompiler_MySQL extends QueryCompiler {
  constructor(client, builder, formatter) {
    super(client, builder, formatter);

    const { returning } = this.single;
    if (returning) {
      this.client.logger.warn(
        '.returning() is not supported by mysql and will not have any effect.'
      );
    }

    this._emptyInsertValue = '() values ()';
  }
  // Compiles an `delete` allowing comments
  del() {
    const sql = super.del();
    if (sql === '') return sql;
    const comments = this.comments();
    return (comments === '' ? '' : comments + ' ') + sql;
  }

  // Compiles an `insert` query, allowing for multiple
  // inserts using a single query statement.
  insert() {
    let sql = super.insert();
    if (sql === '') return sql;
    const comments = this.comments();
    sql = (comments === '' ? '' : comments + ' ') + sql;

    const { ignore, merge, insert } = this.single;
    if (ignore) sql = sql.replace('insert into', 'insert ignore into');
    if (merge) {
      sql += this._merge(merge.updates, insert);
      const wheres = this.where();
      if (wheres) {
        throw new Error(
          '.onConflict().merge().where() is not supported for mysql'
        );
      }
    }

    return sql;
  }

  upsert() {
    const upsertValues = this.single.upsert || [];
    const sql = this.with() + `replace into ${this.tableName} `;
    const body = this._insertBody(upsertValues);
    return body === '' ? '' : sql + body;
  }

  // Compiles merge for onConflict, allowing for different merge strategies
  _merge(updates, insert) {
    const sql = ' on duplicate key update ';
    if (updates && Array.isArray(updates)) {
      // update subset of columns
      return (
        sql +
        updates
          .map((column) =>
            wrapAsIdentifier(column, this.formatter.builder, this.client)
          )
          .map((column) => `${column} = values(${column})`)
          .join(', ')
      );
    } else if (updates && typeof updates === 'object') {
      const updateData = this._prepUpdate(updates);
      return sql + updateData.join(',');
    } else {
      const insertData = this._prepInsert(insert);
      if (typeof insertData === 'string') {
        throw new Error(
          'If using merge with a raw insert query, then updates must be provided'
        );
      }

      return (
        sql +
        insertData.columns
          .map((column) => wrapAsIdentifier(column, this.builder, this.client))
          .map((column) => `${column} = values(${column})`)
          .join(', ')
      );
    }
  }

  // Update method, including joins, wheres, order & limits.
  update() {
    const comments = this.comments();
    const withSQL = this.with();
    const join = this.join();
    const updates = this._prepUpdate(this.single.update);
    const where = this.where();
    const order = this.order();
    const limit = this.limit();
    return (
      (comments === '' ? '' : comments + ' ') +
      withSQL +
      `update ${this.tableName}` +
      (join ? ` ${join}` : '') +
      ' set ' +
      updates.join(', ') +
      (where ? ` ${where}` : '') +
      (order ? ` ${order}` : '') +
      (limit ? ` ${limit}` : '')
    );
  }

  forUpdate() {
    return 'for update';
  }

  forShare() {
    return 'lock in share mode';
  }

  // Only supported on MySQL 8.0+
  skipLocked() {
    return 'skip locked';
  }

  // Supported on MySQL 8.0+ and MariaDB 10.3.0+
  noWait() {
    return 'nowait';
  }

  // Compiles a `columnInfo` query.
  columnInfo() {
    const column = this.single.columnInfo;

    // The user may have specified a custom wrapIdentifier function in the config. We
    // need to run the identifiers through that function, but not format them as
    // identifiers otherwise.
    const table = this.client.customWrapIdentifier(this.single.table, identity);

    return {
      sql: 'select * from information_schema.columns where table_name = ? and table_schema = ?',
      bindings: [table, this.client.database()],
      output(resp) {
        const out = resp.reduce(function (columns, val) {
          columns[val.COLUMN_NAME] = {
            defaultValue:
              val.COLUMN_DEFAULT === 'NULL' ? null : val.COLUMN_DEFAULT,
            type: val.DATA_TYPE,
            maxLength: val.CHARACTER_MAXIMUM_LENGTH,
            nullable: val.IS_NULLABLE === 'YES',
          };
          return columns;
        }, {});
        return (column && out[column]) || out;
      },
    };
  }

  limit() {
    const noLimit = !this.single.limit && this.single.limit !== 0;
    if (noLimit && !this.single.offset) return '';

    // Workaround for offset only.
    // see: http://stackoverflow.com/questions/255517/mysql-offset-infinite-rows
    const limit =
      this.single.offset && noLimit
        ? '18446744073709551615'
        : this._getValueOrParameterFromAttribute('limit');
    return `limit ${limit}`;
  }

  whereBasic(statement) {
    assert(
      !isPlainObjectOrArray(statement.value),
      'The values in where clause must not be object or array.'
    );

    return super.whereBasic(statement);
  }

  whereRaw(statement) {
    assert(
      isEmpty(statement.value.bindings) ||
        !Object.values(statement.value.bindings).some(isPlainObjectOrArray),
      'The values in where clause must not be object or array.'
    );

    return super.whereRaw(statement);
  }

  whereLike(statement) {
    return `${this._columnClause(statement)} ${this._not(
      statement,
      'like '
    )}${this._valueClause(statement)} COLLATE utf8_bin`;
  }

  whereILike(statement) {
    return `${this._columnClause(statement)} ${this._not(
      statement,
      'like '
    )}${this._valueClause(statement)}`;
  }

  // Json functions
  jsonExtract(params) {
    return this._jsonExtract(['json_extract', 'json_unquote'], params);
  }

  jsonSet(params) {
    return this._jsonSet('json_set', params);
  }

  jsonInsert(params) {
    return this._jsonSet('json_insert', params);
  }

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

  whereJsonObject(statement) {
    return this._not(
      statement,
      `json_contains(${this._columnClause(statement)}, ${this._jsonValueClause(
        statement
      )})`
    );
  }

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

  whereJsonSupersetOf(statement) {
    return this._not(
      statement,
      `json_contains(${wrap_(
        statement.column,
        undefined,
        this.builder,
        this.client,
        this.bindingsHolder
      )},${this._jsonValueClause(statement)})`
    );
  }

  whereJsonSubsetOf(statement) {
    return this._not(
      statement,
      `json_contains(${this._jsonValueClause(statement)},${wrap_(
        statement.column,
        undefined,
        this.builder,
        this.client,
        this.bindingsHolder
      )})`
    );
  }

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

// Set the QueryBuilder & QueryCompiler on the client object,
// in case anyone wants to modify things to suit their own purposes.
module.exports = QueryCompiler_MySQL;
