// SQLite3_DDL
//
// All of the SQLite3 specific DDL helpers for renaming/dropping
// columns and changing datatypes.
// -------

const identity = require('lodash/identity');
const { nanonum } = require('../../../util/nanoid');
const {
  copyData,
  dropOriginal,
  renameTable,
  getTableSql,
  isForeignCheckEnabled,
  setForeignCheck,
  executeForeignCheck,
} = require('./internal/sqlite-ddl-operations');
const { parseCreateTable, parseCreateIndex } = require('./internal/parser');
const {
  compileCreateTable,
  compileCreateIndex,
} = require('./internal/compiler');
const { isEqualId, includesId } = require('./internal/utils');

// So altering the schema in SQLite3 is a major pain.
// We have our own object to deal with the renaming and altering the types
// for sqlite3 things.
class SQLite3_DDL {
  constructor(client, tableCompiler, pragma, connection) {
    this.client = client;
    this.tableCompiler = tableCompiler;
    this.pragma = pragma;
    this.tableNameRaw = this.tableCompiler.tableNameRaw;
    this.alteredName = `_knex_temp_alter${nanonum(3)}`;
    this.connection = connection;
    this.formatter = (value) =>
      this.client.customWrapIdentifier(value, identity);
    this.wrap = (value) => this.client.wrapIdentifierImpl(value);
  }

  tableName() {
    return this.formatter(this.tableNameRaw);
  }

  getTableSql() {
    const tableName = this.tableName();

    return this.client.transaction(
      async (trx) => {
        trx.disableProcessing();
        const result = await trx.raw(getTableSql(tableName));
        trx.enableProcessing();

        return {
          createTable: result.filter((create) => create.type === 'table')[0]
            .sql,
          createIndices: result
            .filter((create) => create.type === 'index')
            .map((create) => create.sql),
        };
      },
      { connection: this.connection, enforceForeignCheck: null }
    );
  }

  async isForeignCheckEnabled() {
    const result = await this.client
      .raw(isForeignCheckEnabled())
      .connection(this.connection);

    return result[0].foreign_keys === 1;
  }

  async setForeignCheck(enable) {
    await this.client.raw(setForeignCheck(enable)).connection(this.connection);
  }

  renameTable(trx) {
    return trx.raw(renameTable(this.alteredName, this.tableName()));
  }

  dropOriginal(trx) {
    return trx.raw(dropOriginal(this.tableName()));
  }

  copyData(trx, columns) {
    return trx.raw(copyData(this.tableName(), this.alteredName, columns));
  }

  async alterColumn(columns) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    parsedTable.columns = parsedTable.columns.map((column) => {
      const newColumnInfo = columns.find((c) => isEqualId(c.name, column.name));

      if (newColumnInfo) {
        column.type = newColumnInfo.type;

        column.constraints.default =
          newColumnInfo.defaultTo !== null
            ? {
                name: null,
                value: newColumnInfo.defaultTo,
                expression: false,
              }
            : null;

        column.constraints.notnull = newColumnInfo.notNull
          ? { name: null, conflict: null }
          : null;

        column.constraints.null = newColumnInfo.notNull
          ? null
          : column.constraints.null;
      }

      return column;
    });

    const newTable = compileCreateTable(parsedTable, this.wrap);

    return this.generateAlterCommands(newTable, createIndices);
  }

  async dropColumn(columns) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    parsedTable.columns = parsedTable.columns.filter(
      (parsedColumn) =>
        parsedColumn.expression || !includesId(columns, parsedColumn.name)
    );

    if (parsedTable.columns.length === 0) {
      throw new Error('Unable to drop last column from table');
    }

    parsedTable.constraints = parsedTable.constraints.filter((constraint) => {
      if (constraint.type === 'PRIMARY KEY' || constraint.type === 'UNIQUE') {
        return constraint.columns.every(
          (constraintColumn) =>
            constraintColumn.expression ||
            !includesId(columns, constraintColumn.name)
        );
      } else if (constraint.type === 'FOREIGN KEY') {
        return (
          constraint.columns.every(
            (constraintColumnName) => !includesId(columns, constraintColumnName)
          ) &&
          (constraint.references.table !== parsedTable.table ||
            constraint.references.columns.every(
              (referenceColumnName) => !includesId(columns, referenceColumnName)
            ))
        );
      } else {
        return true;
      }
    });

    const newColumns = parsedTable.columns.map((column) => column.name);

    const newTable = compileCreateTable(parsedTable, this.wrap);

    const newIndices = [];
    for (const createIndex of createIndices) {
      const parsedIndex = parseCreateIndex(createIndex);

      parsedIndex.columns = parsedIndex.columns.filter(
        (parsedColumn) =>
          parsedColumn.expression || !includesId(columns, parsedColumn.name)
      );

      if (parsedIndex.columns.length > 0) {
        newIndices.push(compileCreateIndex(parsedIndex, this.wrap));
      }
    }

    return this.alter(newTable, newIndices, newColumns);
  }

  async dropForeign(columns, foreignKeyName) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    if (!foreignKeyName) {
      parsedTable.columns = parsedTable.columns.map((column) => ({
        ...column,
        references: includesId(columns, column.name) ? null : column.references,
      }));
    }

    parsedTable.constraints = parsedTable.constraints.filter((constraint) => {
      if (constraint.type === 'FOREIGN KEY') {
        if (foreignKeyName) {
          return (
            !constraint.name || !isEqualId(constraint.name, foreignKeyName)
          );
        }

        return constraint.columns.every(
          (constraintColumnName) => !includesId(columns, constraintColumnName)
        );
      } else {
        return true;
      }
    });

    const newTable = compileCreateTable(parsedTable, this.wrap);

    return this.alter(newTable, createIndices);
  }

  async dropPrimary(constraintName) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    parsedTable.columns = parsedTable.columns.map((column) => ({
      ...column,
      primary: null,
    }));

    parsedTable.constraints = parsedTable.constraints.filter((constraint) => {
      if (constraint.type === 'PRIMARY KEY') {
        if (constraintName) {
          return (
            !constraint.name || !isEqualId(constraint.name, constraintName)
          );
        } else {
          return false;
        }
      } else {
        return true;
      }
    });

    const newTable = compileCreateTable(parsedTable, this.wrap);

    return this.alter(newTable, createIndices);
  }

  async primary(columns, constraintName) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    parsedTable.columns = parsedTable.columns.map((column) => ({
      ...column,
      primary: null,
    }));

    parsedTable.constraints = parsedTable.constraints.filter(
      (constraint) => constraint.type !== 'PRIMARY KEY'
    );

    parsedTable.constraints.push({
      type: 'PRIMARY KEY',
      name: constraintName || null,
      columns: columns.map((column) => ({
        name: column,
        expression: false,
        collation: null,
        order: null,
      })),
      conflict: null,
    });

    const newTable = compileCreateTable(parsedTable, this.wrap);

    return this.alter(newTable, createIndices);
  }

  async foreign(foreignInfo) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    parsedTable.constraints.push({
      type: 'FOREIGN KEY',
      name: foreignInfo.keyName || null,
      columns: foreignInfo.column,
      references: {
        table: foreignInfo.inTable,
        columns: foreignInfo.references,
        delete: foreignInfo.onDelete || null,
        update: foreignInfo.onUpdate || null,
        match: null,
        deferrable: null,
      },
    });

    const newTable = compileCreateTable(parsedTable, this.wrap);

    return this.generateAlterCommands(newTable, createIndices);
  }

  async setNullable(column, isNullable) {
    const { createTable, createIndices } = await this.getTableSql();

    const parsedTable = parseCreateTable(createTable);
    parsedTable.table = this.alteredName;

    const parsedColumn = parsedTable.columns.find((c) =>
      isEqualId(column, c.name)
    );

    if (!parsedColumn) {
      throw new Error(
        `.setNullable: Column ${column} does not exist in table ${this.tableName()}.`
      );
    }

    parsedColumn.constraints.notnull = isNullable
      ? null
      : { name: null, conflict: null };

    parsedColumn.constraints.null = isNullable
      ? parsedColumn.constraints.null
      : null;

    const newTable = compileCreateTable(parsedTable, this.wrap);

    return this.generateAlterCommands(newTable, createIndices);
  }

  async alter(newSql, createIndices, columns) {
    // When already inside a transaction, we cannot change the foreign_keys
    // pragma (SQLite silently ignores pragma changes within transactions).
    // Use `null` to leave it as-is and avoid the nested-transaction guard.
    const enforceForeignCheck = this.client.transacting ? null : false;
    await this.client.transaction(
      async (trx) => {
        await trx.raw(newSql);
        await this.copyData(trx, columns);
        await this.dropOriginal(trx);
        await this.renameTable(trx);

        for (const createIndex of createIndices) {
          await trx.raw(createIndex);
        }
      },
      { connection: this.connection, enforceForeignCheck }
    );
  }

  async generateAlterCommands(newSql, createIndices, columns) {
    const sql = [];
    const pre = [];
    const post = [];
    let check = null;

    sql.push(newSql);
    sql.push(copyData(this.tableName(), this.alteredName, columns));
    sql.push(dropOriginal(this.tableName()));
    sql.push(renameTable(this.alteredName, this.tableName()));

    for (const createIndex of createIndices) {
      sql.push(createIndex);
    }

    const isForeignCheckEnabled = await this.isForeignCheckEnabled();

    if (isForeignCheckEnabled) {
      pre.push(setForeignCheck(false));
      post.push(setForeignCheck(true));

      check = executeForeignCheck();
    }

    return { pre, sql, check, post };
  }
}

module.exports = SQLite3_DDL;
