/* eslint max-len:0 */

// Table Compiler
// -------
const {
  pushAdditional,
  pushQuery,
  unshiftQuery,
} = require('./internal/helpers');
const helpers = require('../util/helpers');
const groupBy = require('lodash/groupBy');
const indexOf = require('lodash/indexOf');
const isEmpty = require('lodash/isEmpty');
const tail = require('lodash/tail');
const { normalizeArr } = require('../util/helpers');

class TableCompiler {
  constructor(client, tableBuilder) {
    this.client = client;
    this.tableBuilder = tableBuilder;
    this._commonBuilder = this.tableBuilder;
    this.method = tableBuilder._method;
    this.schemaNameRaw = tableBuilder._schemaName;
    this.tableNameRaw = tableBuilder._tableName;
    this.tableNameLikeRaw = tableBuilder._tableNameLike;
    this.single = tableBuilder._single;
    this.grouped = groupBy(tableBuilder._statements, 'grouping');

    this.formatter = client.formatter(tableBuilder);
    this.bindings = [];
    this.formatter.bindings = this.bindings;
    this.bindingsHolder = this;

    this.sequence = [];
    this._formatting = client.config && client.config.formatting;

    this.checksCount = 0;
  }

  // Convert the tableCompiler toSQL
  toSQL() {
    this[this.method]();
    return this.sequence;
  }

  // Column Compilation
  // -------

  // If this is a table "creation", we need to first run through all
  // of the columns to build them into a single string,
  // and then run through anything else and push it to the query sequence.
  create(ifNot, like) {
    const columnBuilders = this.getColumns();
    const columns = columnBuilders.map((col) => col.toSQL());
    const columnTypes = this.getColumnTypes(columns);
    if (this.createAlterTableMethods) {
      this.alterTableForCreate(columnTypes);
    }
    this.createQuery(columnTypes, ifNot, like);
    this.columnQueries(columns);
    delete this.single.comment;
    this.alterTable();
  }

  // Only create the table if it doesn't exist.
  createIfNot() {
    this.create(true);
  }

  createLike() {
    this.create(false, true);
  }

  createLikeIfNot() {
    this.create(true, true);
  }

  // If we're altering the table, we need to one-by-one
  // go through and handle each of the queries associated
  // with altering the table's schema.
  alter() {
    const addColBuilders = this.getColumns();
    const addColumns = addColBuilders.map((col) => col.toSQL());
    const alterColBuilders = this.getColumns('alter');
    const alterColumns = alterColBuilders.map((col) => col.toSQL());
    const addColumnTypes = this.getColumnTypes(addColumns);
    const alterColumnTypes = this.getColumnTypes(alterColumns);

    this.addColumns(addColumnTypes);
    this.alterColumns(alterColumnTypes, alterColBuilders);
    this.columnQueries(addColumns);
    this.columnQueries(alterColumns);
    this.alterTable();
  }

  foreign(foreignData) {
    if (foreignData.inTable && foreignData.references) {
      const keyName = foreignData.keyName
        ? this.formatter.wrap(foreignData.keyName)
        : this._indexCommand('foreign', this.tableNameRaw, foreignData.column);
      const column = this.formatter.columnize(foreignData.column);
      const references = this.formatter.columnize(foreignData.references);
      const inTable = this.formatter.wrap(foreignData.inTable);
      const onUpdate = foreignData.onUpdate
        ? (this.lowerCase ? ' on update ' : ' ON UPDATE ') +
          foreignData.onUpdate
        : '';
      const onDelete = foreignData.onDelete
        ? (this.lowerCase ? ' on delete ' : ' ON DELETE ') +
          foreignData.onDelete
        : '';
      const deferrable = foreignData.deferrable
        ? this.lowerCase
          ? ` deferrable initially ${foreignData.deferrable.toLowerCase()} `
          : ` DEFERRABLE INITIALLY ${foreignData.deferrable.toUpperCase()} `
        : '';
      if (this.lowerCase) {
        this.pushQuery(
          (!this.forCreate ? `alter table ${this.tableName()} add ` : '') +
            'constraint ' +
            keyName +
            ' ' +
            'foreign key (' +
            column +
            ') references ' +
            inTable +
            ' (' +
            references +
            ')' +
            onUpdate +
            onDelete +
            deferrable
        );
      } else {
        this.pushQuery(
          (!this.forCreate ? `ALTER TABLE ${this.tableName()} ADD ` : '') +
            'CONSTRAINT ' +
            keyName +
            ' ' +
            'FOREIGN KEY (' +
            column +
            ') REFERENCES ' +
            inTable +
            ' (' +
            references +
            ')' +
            onUpdate +
            onDelete +
            deferrable
        );
      }
    }
  }

  // Get all of the column sql & bindings individually for building the table queries.
  getColumnTypes(columns) {
    return columns.reduce(
      function (memo, columnSQL) {
        const column = columnSQL[0];
        memo.sql.push(column.sql);
        memo.bindings.concat(column.bindings);
        return memo;
      },
      { sql: [], bindings: [] }
    );
  }

  // Adds all of the additional queries from the "column"
  columnQueries(columns) {
    const queries = columns.reduce(function (memo, columnSQL) {
      const column = tail(columnSQL);
      if (!isEmpty(column)) return memo.concat(column);
      return memo;
    }, []);
    for (const q of queries) {
      this.pushQuery(q);
    }
  }

  // All of the columns to "add" for the query
  addColumns(columns, prefix) {
    prefix = prefix || this.addColumnsPrefix;

    if (columns.sql.length > 0) {
      const columnSql = columns.sql.map((column) => {
        return prefix + column;
      });
      this.pushQuery({
        sql:
          (this.lowerCase ? 'alter table ' : 'ALTER TABLE ') +
          this.tableName() +
          ' ' +
          columnSql.join(', '),
        bindings: columns.bindings,
      });
    }
  }

  alterColumns(columns, colBuilders) {
    if (columns.sql.length > 0) {
      this.addColumns(columns, this.alterColumnsPrefix, colBuilders);
    }
  }

  // Compile the columns as needed for the current create or alter table
  getColumns(method) {
    const columns = this.grouped.columns || [];
    method = method || 'add';

    const queryContext = this.tableBuilder.queryContext();

    return columns
      .filter((column) => column.builder._method === method)
      .map((column) => {
        // pass queryContext down to columnBuilder but do not overwrite it if already set
        if (
          queryContext !== undefined &&
          column.builder.queryContext() === undefined
        ) {
          column.builder.queryContext(queryContext);
        }
        return this.client.columnCompiler(this, column.builder);
      });
  }

  tableName() {
    const name = this.schemaNameRaw
      ? `${this.schemaNameRaw}.${this.tableNameRaw}`
      : this.tableNameRaw;

    return this.formatter.wrap(name);
  }

  tableNameLike() {
    const name = this.schemaNameRaw
      ? `${this.schemaNameRaw}.${this.tableNameLikeRaw}`
      : this.tableNameLikeRaw;

    return this.formatter.wrap(name);
  }

  // Generate all of the alter column statements necessary for the query.
  alterTable() {
    const alterTable = this.grouped.alterTable || [];
    for (let i = 0, l = alterTable.length; i < l; i++) {
      const statement = alterTable[i];
      if (this[statement.method]) {
        this[statement.method].apply(this, statement.args);
      } else {
        this.client.logger.error(`Debug: ${statement.method} does not exist`);
      }
    }
    for (const item in this.single) {
      if (typeof this[item] === 'function') this[item](this.single[item]);
    }
  }

  alterTableForCreate(columnTypes) {
    this.forCreate = true;
    const savedSequence = this.sequence;
    const alterTable = this.grouped.alterTable || [];
    this.grouped.alterTable = [];
    for (let i = 0, l = alterTable.length; i < l; i++) {
      const statement = alterTable[i];
      if (indexOf(this.createAlterTableMethods, statement.method) < 0) {
        this.grouped.alterTable.push(statement);
        continue;
      }
      if (this[statement.method]) {
        this.sequence = [];
        this[statement.method].apply(this, statement.args);
        columnTypes.sql.push(this.sequence[0].sql);
      } else {
        this.client.logger.error(`Debug: ${statement.method} does not exist`);
      }
    }
    this.sequence = savedSequence;
    this.forCreate = false;
  }

  // Drop the index on the current table.
  dropIndex(value) {
    this.pushQuery(`drop index${value}`);
  }

  dropUnique() {
    throw new Error('Method implemented in the dialect driver');
  }
  dropUniqueIfExists() {
    throw new Error('Method implemented in the dialect driver');
  }

  dropForeign() {
    throw new Error('Method implemented in the dialect driver');
  }

  dropForeignIfExists() {
    throw new Error('Method implemented in the dialect driver');
  }

  dropPrimaryIfExists() {
    throw new Error('Method implemented in the dialect driver');
  }

  dropColumn() {
    const columns = helpers.normalizeArr.apply(null, arguments);
    const drops = (Array.isArray(columns) ? columns : [columns]).map(
      (column) => {
        return this.dropColumnPrefix + this.formatter.wrap(column);
      }
    );
    this.pushQuery(
      (this.lowerCase ? 'alter table ' : 'ALTER TABLE ') +
        this.tableName() +
        ' ' +
        drops.join(', ')
    );
  }

  //Default implementation of setNullable. Overwrite on dialect-specific tablecompiler when needed
  //(See postgres/mssql for reference)
  _setNullableState(column, nullable) {
    const tableName = this.tableName();
    const columnName = this.formatter.columnize(column);
    const alterColumnPrefix = this.alterColumnsPrefix;
    return this.pushQuery({
      sql: 'SELECT 1',
      output: () => {
        return this.client
          .queryBuilder()
          .from(this.tableNameRaw)
          .columnInfo(column)
          .then((columnInfo) => {
            if (isEmpty(columnInfo)) {
              throw new Error(
                `.setNullable: Column ${columnName} does not exist in table ${tableName}.`
              );
            }
            const nullableType = nullable ? 'null' : 'not null';
            const columnType =
              columnInfo.type +
              (columnInfo.maxLength ? `(${columnInfo.maxLength})` : '');
            const defaultValue =
              columnInfo.defaultValue !== null &&
              columnInfo.defaultValue !== void 0
                ? `default '${columnInfo.defaultValue}'`
                : '';
            const sql = `alter table ${tableName} ${alterColumnPrefix} ${columnName} ${columnType} ${nullableType} ${defaultValue}`;
            return this.client.raw(sql);
          });
      },
    });
  }

  setNullable(column) {
    return this._setNullableState(column, true);
  }

  dropNullable(column) {
    return this._setNullableState(column, false);
  }

  dropChecks(checkConstraintNames) {
    if (checkConstraintNames === undefined) return '';
    checkConstraintNames = normalizeArr(checkConstraintNames);
    const tableName = this.tableName();
    const sql = `alter table ${tableName} ${checkConstraintNames
      .map((constraint) => `drop constraint ${constraint}`)
      .join(', ')}`;
    this.pushQuery(sql);
  }

  check(checkPredicate, bindings, constraintName) {
    const tableName = this.tableName();
    let checkConstraint = constraintName;
    if (!checkConstraint) {
      this.checksCount++;
      checkConstraint = tableName + '_' + this.checksCount;
    }
    const sql = `alter table ${tableName} add constraint ${checkConstraint} check(${checkPredicate})`;
    this.pushQuery(sql);
  }

  _addChecks() {
    if (this.grouped.checks) {
      return (
        ', ' +
        this.grouped.checks
          .map((c) => {
            return `${
              c.args[2] ? 'constraint ' + c.args[2] + ' ' : ''
            }check (${this.client.raw(c.args[0], c.args[1])})`;
          })
          .join(', ')
      );
    }
    return '';
  }

  // If no name was specified for this index, we will create one using a basic
  // convention of the table name, followed by the columns, followed by an
  // index type, such as primary or index, which makes the index unique.
  _indexCommand(type, tableName, columns) {
    if (!Array.isArray(columns)) columns = columns ? [columns] : [];
    const table = tableName.replace(/\.|-/g, '_');
    const indexName = (
      table +
      '_' +
      columns.join('_') +
      '_' +
      type
    ).toLowerCase();
    return this.formatter.wrap(indexName);
  }

  _getPrimaryKeys() {
    return (this.grouped.alterTable || [])
      .filter((a) => a.method === 'primary')
      .flatMap((a) => a.args)
      .flat();
  }

  _canBeAddPrimaryKey(options) {
    return options.primaryKey && this._getPrimaryKeys().length === 0;
  }

  _getIncrementsColumnNames() {
    return this.grouped.columns
      .filter((c) => c.builder._type === 'increments')
      .map((c) => c.builder._args[0]);
  }

  _getBigIncrementsColumnNames() {
    return this.grouped.columns
      .filter((c) => c.builder._type === 'bigincrements')
      .map((c) => c.builder._args[0]);
  }
}

TableCompiler.prototype.pushQuery = pushQuery;
TableCompiler.prototype.pushAdditional = pushAdditional;
TableCompiler.prototype.unshiftQuery = unshiftQuery;
TableCompiler.prototype.lowerCase = true;
TableCompiler.prototype.createAlterTableMethods = null;
TableCompiler.prototype.addColumnsPrefix = 'add column ';
TableCompiler.prototype.alterColumnsPrefix = 'alter column ';
TableCompiler.prototype.modifyColumnPrefix = 'modify column ';
TableCompiler.prototype.dropColumnPrefix = 'drop column ';

module.exports = TableCompiler;
