import {
  type AnyColumn,
  type AnySchema,
  type AnyTable,
  compileForeignKey,
  type NameVariants,
} from "../schema/create";
import type { RelationMode } from "../shared/config";
import type { Provider } from "../shared/providers";
import { deepEqual } from "../utils/deep-equal";
import {
  type ColumnOperation,
  isUpdated,
  type MigrationOperation,
} from "./shared";

type Operation = MigrationOperation & { enforce?: "pre" | "post" };

/**
 * Generate migration by comparing two schemas
 */
export function generateMigrationFromSchema(
  old: AnySchema,
  schema: AnySchema,
  options: {
    provider: Provider;
    relationMode?: RelationMode;

    /**
     * Drop tables if no longer exist in latest schema.
     *
     * This only detects tables from schema, user tables won't be affected.
     */
    dropUnusedTables?: boolean;
    dropUnusedColumns?: boolean;
  }
): MigrationOperation[] {
  const {
    provider,
    relationMode = provider === "mssql" || provider === "mongodb"
      ? "fumadb"
      : "foreign-keys",
    dropUnusedTables = true,
    dropUnusedColumns = true,
  } = options;

  function getName(names: NameVariants) {
    return provider === "mongodb" ? names.mongodb : names.sql;
  }

  function columnActionToOperation(
    tableName: string,
    actions: ColumnOperation[]
  ): MigrationOperation[] {
    if (actions.length === 0) return [];

    if (
      provider === "mysql" ||
      provider === "postgresql" ||
      provider === "mongodb"
    ) {
      return [
        {
          type: "update-table",
          name: tableName,
          value: actions,
        },
      ];
    }

    return actions.map((action) => ({
      type: "update-table",
      name: tableName,
      value: [action],
    }));
  }

  function onUniqueConstraintCheck(prev: AnyTable, next: AnyTable) {
    const operations: Operation[] = [];
    const newConstraints = next.getUniqueConstraints();
    const oldConstraints = prev.getUniqueConstraints();

    for (const con of newConstraints) {
      const oldCon = oldConstraints.find((item) => item.name === con.name);
      const columnNames = con.columns.map((col) => getName(col.names));

      if (!oldCon) {
        operations.push({
          type: "add-unique-constraint",
          name: con.name,
          table: getName(next.names),
          columns: columnNames,
        });
        continue;
      }

      if (
        deepEqual(
          columnNames,
          oldCon.columns.map((col) => getName(col.names))
        )
      )
        continue;

      operations.push(
        {
          type: "drop-unique-constraint",
          table: getName(next.names),
          name: con.name,
        },
        {
          type: "add-unique-constraint",
          table: getName(next.names),
          name: con.name,
          columns: columnNames,
        }
      );
    }

    for (const con of oldConstraints) {
      const isUnused = newConstraints.every((item) => item.name !== con.name);

      if (isUnused)
        operations.push({
          type: "drop-unique-constraint",
          table: getName(next.names),
          name: con.name,
        });
    }

    return operations;
  }

  function onTableRenameCheck(oldTable: AnyTable, newTable: AnyTable) {
    const operations: Operation[] = [];

    if (getName(newTable.names) !== getName(oldTable.names)) {
      operations.push({
        type: "rename-table",
        from: getName(oldTable.names),
        to: getName(newTable.names),
        enforce: "pre",
      });
    }

    return operations;
  }

  function onTableColumnsCheck(
    oldTable: AnyTable,
    newTable: AnyTable
  ): Operation[] {
    const colActions: ColumnOperation[] = [];

    for (const column of Object.values(newTable.columns)) {
      const oldColumn = oldTable.columns[column.ormName];

      if (!oldColumn) {
        colActions.push({
          type: "create-column",
          value: column,
        });
        continue;
      }

      if (getName(column.names) !== getName(oldColumn.names)) {
        colActions.push({
          type: "rename-column",
          from: getName(oldColumn.names),
          to: getName(column.names),
        });
      }

      /**
       * Generate hash to compare default values
       */
      function hashDefaultValue(col: AnyColumn) {
        if (!col.default || "runtime" in col.default) return;
        if (col.type === "string" && provider === "mysql") return;

        return col.default.value;
      }

      const action: ColumnOperation = {
        type: "update-column",
        name: getName(column.names),
        updateDataType: column.type !== oldColumn.type,
        updateDefault: !deepEqual(
          hashDefaultValue(column),
          hashDefaultValue(oldColumn)
        ),
        updateNullable: column.isNullable !== oldColumn.isNullable,
        value: column,
      };

      if (isUpdated(action)) colActions.push(action);
    }

    return columnActionToOperation(getName(newTable.names), colActions);
  }

  function onTableForeignKeyCheck(
    oldTable: AnyTable,
    newTable: AnyTable
  ): Operation[] {
    const tableName = getName(newTable.names);
    const operations: Operation[] = [];
    if (relationMode === "fumadb") return operations;

    for (const foreignKey of newTable.foreignKeys) {
      const compiled = compileForeignKey(foreignKey, "sql");
      const oldKey = oldTable.foreignKeys.find(
        (key) => key.name === foreignKey.name
      );

      if (!oldKey) {
        operations.push({
          type: "add-foreign-key",
          table: tableName,
          value: compiled,
          enforce: "post",
        });
        continue;
      }

      const isUpdated = !deepEqual(compiled, compileForeignKey(oldKey, "sql"));
      if (isUpdated) {
        operations.push(
          {
            type: "drop-foreign-key",
            name: oldKey.name,
            table: tableName,
            enforce: "post",
          },
          {
            type: "add-foreign-key",
            table: tableName,
            value: compiled,
            enforce: "post",
          }
        );
      }
    }

    return operations;
  }

  function onTableUnusedForeignKeyCheck(
    oldTable: AnyTable,
    newTable: AnyTable
  ) {
    const operations: Operation[] = [];

    for (const oldKey of oldTable.foreignKeys) {
      const isUnused = newTable.foreignKeys.every(
        (key) => key.name !== oldKey.name
      );

      if (!isUnused) continue;
      operations.push({
        type: "drop-foreign-key",
        name: oldKey.name,
        table: getName(oldTable.names),
        enforce: "pre",
      });
    }

    return operations;
  }

  function onTableUnusedColumnsCheck(
    oldTable: AnyTable,
    newTable: AnyTable
  ): Operation[] {
    // this check happens after unique constraint check
    const constraints = newTable.getUniqueConstraints();
    const operations: Operation[] = [];

    for (const oldColumn of Object.values(oldTable.columns)) {
      const isUnused = !newTable.columns[oldColumn.ormName];
      const isRequired = !oldColumn.isNullable && !oldColumn.default;
      const shouldDrop = isUnused && (dropUnusedColumns || isRequired);

      if (!shouldDrop) continue;

      // mssql doesn't auto drop unique index/constraint
      if (provider === "mssql" && oldColumn.isUnique) {
        for (const con of constraints) {
          if (con.columns.every((col) => col.ormName !== oldColumn.ormName))
            continue;

          operations.push({
            type: "drop-unique-constraint",
            name: con.name,
            table: getName(newTable.names),
          });
        }
      }

      operations.push({
        type: "update-table",
        name: getName(newTable.names),
        value: [{ type: "drop-column", name: getName(oldColumn.names) }],
        enforce: "post",
      });
    }

    return operations;
  }

  function reorder(operations: Operation[]) {
    const out: MigrationOperation[] = [];
    for (const item of operations) {
      if (item.enforce === "pre") out.push(item);
    }

    for (const item of operations) {
      if (!item.enforce) out.push(item);
    }

    for (const item of operations) {
      if (item.enforce === "post") out.push(item);
    }

    return out;
  }

  function generate() {
    const operations: Operation[] = [];

    for (const table of Object.values(schema.tables)) {
      const oldTable = old.tables[table.ormName];
      if (!oldTable) {
        operations.push({
          type: "create-table",
          value: table,
        });
        continue;
      }

      operations.push(
        ...onTableUnusedForeignKeyCheck(oldTable, table),
        ...onTableRenameCheck(oldTable, table),
        ...onTableColumnsCheck(oldTable, table),
        ...onUniqueConstraintCheck(oldTable, table),
        ...onTableForeignKeyCheck(oldTable, table),
        ...onTableUnusedColumnsCheck(oldTable, table)
      );
    }

    for (const oldTable of Object.values(old.tables)) {
      if (!schema.tables[oldTable.ormName] && dropUnusedTables) {
        operations.push({
          type: "drop-table",
          name: getName(oldTable.names),
          enforce: "post",
        });
      }
    }

    return reorder(operations);
  }

  return generate();
}
