import { ArrayType, BooleanType, DateTimeType, inspect, JsonType, parseJsonSafe, Utils } from '@mikro-orm/core';
/**
 * Compares two Schemas and return an instance of SchemaDifference.
 */
export class SchemaComparator {
  #helper;
  #logger;
  #platform;
  constructor(platform) {
    this.#platform = platform;
    this.#helper = this.#platform.getSchemaHelper();
    this.#logger = this.#platform.getConfig().getLogger();
  }
  /**
   * Returns a SchemaDifference object containing the differences between the schemas fromSchema and toSchema.
   *
   * The returned differences are returned in such a way that they contain the
   * operations to change the schema stored in fromSchema to the schema that is
   * stored in toSchema.
   */
  compare(fromSchema, toSchema, inverseDiff) {
    const diff = {
      newTables: {},
      removedTables: {},
      changedTables: {},
      newViews: {},
      changedViews: {},
      removedViews: {},
      orphanedForeignKeys: [],
      newNativeEnums: [],
      removedNativeEnums: [],
      newNamespaces: new Set(),
      removedNamespaces: new Set(),
      fromSchema,
    };
    const foreignKeysToTable = {};
    for (const namespace of toSchema.getNamespaces()) {
      if (fromSchema.hasNamespace(namespace) || namespace === this.#platform.getDefaultSchemaName()) {
        continue;
      }
      diff.newNamespaces.add(namespace);
    }
    for (const namespace of fromSchema.getNamespaces()) {
      if (toSchema.hasNamespace(namespace) || namespace === this.#platform.getDefaultSchemaName()) {
        continue;
      }
      diff.removedNamespaces.add(namespace);
    }
    for (const [key, nativeEnum] of Object.entries(toSchema.getNativeEnums())) {
      if (fromSchema.hasNativeEnum(key)) {
        continue;
      }
      if (nativeEnum.schema === '*' && fromSchema.hasNativeEnum(`${toSchema.name}.${key}`)) {
        continue;
      }
      diff.newNativeEnums.push(nativeEnum);
    }
    for (const [key, nativeEnum] of Object.entries(fromSchema.getNativeEnums())) {
      if (toSchema.hasNativeEnum(key)) {
        continue;
      }
      if (
        key.startsWith(`${fromSchema.name}.`) &&
        (fromSchema.name !== toSchema.name ||
          toSchema.getNativeEnum(key.substring(fromSchema.name.length + 1))?.schema === '*')
      ) {
        continue;
      }
      diff.removedNativeEnums.push(nativeEnum);
    }
    for (const table of toSchema.getTables()) {
      const tableName = table.getShortestName(false);
      if (!fromSchema.hasTable(tableName)) {
        diff.newTables[tableName] = toSchema.getTable(tableName);
      } else {
        const tableDifferences = this.diffTable(
          fromSchema.getTable(tableName),
          toSchema.getTable(tableName),
          inverseDiff?.changedTables[tableName],
        );
        if (tableDifferences !== false) {
          diff.changedTables[tableName] = tableDifferences;
        }
      }
    }
    // Check if there are tables removed
    for (let table of fromSchema.getTables()) {
      const tableName = table.getShortestName();
      table = fromSchema.getTable(tableName);
      if (!toSchema.hasTable(tableName)) {
        diff.removedTables[tableName] = table;
      }
      // also remember all foreign keys that point to a specific table
      for (const foreignKey of Object.values(table.getForeignKeys())) {
        if (!foreignKeysToTable[foreignKey.referencedTableName]) {
          foreignKeysToTable[foreignKey.referencedTableName] = [];
        }
        foreignKeysToTable[foreignKey.referencedTableName].push(foreignKey);
      }
    }
    for (const table of Object.values(diff.removedTables)) {
      const tableName = (table.schema ? table.schema + '.' : '') + table.name;
      if (!foreignKeysToTable[tableName]) {
        continue;
      }
      diff.orphanedForeignKeys.push(...foreignKeysToTable[tableName]);
      // Deleting duplicated foreign keys present both on the orphanedForeignKey and the removedForeignKeys from changedTables.
      for (const foreignKey of foreignKeysToTable[tableName]) {
        const localTableName = foreignKey.localTableName;
        if (!diff.changedTables[localTableName]) {
          continue;
        }
        for (const [key, fk] of Object.entries(diff.changedTables[localTableName].removedForeignKeys)) {
          // We check if the key is from the removed table, if not we skip.
          if (tableName !== fk.referencedTableName) {
            continue;
          }
          delete diff.changedTables[localTableName].removedForeignKeys[key];
        }
      }
    }
    // Compare views
    for (const toView of toSchema.getViews()) {
      const viewName = toView.schema ? `${toView.schema}.${toView.name}` : toView.name;
      if (!fromSchema.hasView(toView.name) && !fromSchema.hasView(viewName)) {
        diff.newViews[viewName] = toView;
        this.log(`view ${viewName} added`);
      } else {
        const fromView = fromSchema.getView(toView.name) ?? fromSchema.getView(viewName);
        if (fromView && this.diffExpression(fromView.definition, toView.definition)) {
          diff.changedViews[viewName] = { from: fromView, to: toView };
          this.log(`view ${viewName} changed`);
        }
      }
    }
    // Check for removed views
    for (const fromView of fromSchema.getViews()) {
      const viewName = fromView.schema ? `${fromView.schema}.${fromView.name}` : fromView.name;
      if (!toSchema.hasView(fromView.name) && !toSchema.hasView(viewName)) {
        diff.removedViews[viewName] = fromView;
        this.log(`view ${viewName} removed`);
      }
    }
    return diff;
  }
  /**
   * Returns the difference between the tables fromTable and toTable.
   * If there are no differences this method returns the boolean false.
   */
  diffTable(fromTable, toTable, inverseTableDiff) {
    let changes = 0;
    const tableDifferences = {
      name: fromTable.getShortestName(),
      addedColumns: {},
      addedForeignKeys: {},
      addedIndexes: {},
      addedChecks: {},
      changedColumns: {},
      changedForeignKeys: {},
      changedIndexes: {},
      changedChecks: {},
      removedColumns: {},
      removedForeignKeys: {},
      removedIndexes: {},
      removedChecks: {},
      renamedColumns: {},
      renamedIndexes: {},
      fromTable,
      toTable,
    };
    if (this.diffComment(fromTable.comment, toTable.comment)) {
      tableDifferences.changedComment = toTable.comment;
      this.log(`table comment changed for ${tableDifferences.name}`, {
        fromTableComment: fromTable.comment,
        toTableComment: toTable.comment,
      });
      changes++;
    }
    const fromTableColumns = fromTable.getColumns();
    const toTableColumns = toTable.getColumns();
    // See if all the columns in "from" table exist in "to" table
    for (const column of toTableColumns) {
      if (fromTable.hasColumn(column.name)) {
        continue;
      }
      tableDifferences.addedColumns[column.name] = column;
      this.log(`column ${tableDifferences.name}.${column.name} of type ${column.type} added`);
      changes++;
    }
    /* See if there are any removed columns in "to" table */
    for (const column of fromTableColumns) {
      // See if column is removed in "to" table.
      if (!toTable.hasColumn(column.name)) {
        tableDifferences.removedColumns[column.name] = column;
        this.log(`column ${tableDifferences.name}.${column.name} removed`);
        changes++;
        continue;
      }
      // See if column has changed properties in "to" table.
      const changedProperties = this.diffColumn(column, toTable.getColumn(column.name), fromTable, true);
      if (changedProperties.size === 0) {
        continue;
      }
      if (changedProperties.size === 1 && changedProperties.has('generated')) {
        tableDifferences.addedColumns[column.name] = toTable.getColumn(column.name);
        tableDifferences.removedColumns[column.name] = column;
        changes++;
        continue;
      }
      tableDifferences.changedColumns[column.name] = {
        oldColumnName: column.name,
        fromColumn: column,
        column: toTable.getColumn(column.name),
        changedProperties,
      };
      this.log(`column ${tableDifferences.name}.${column.name} changed`, { changedProperties });
      changes++;
    }
    this.detectColumnRenamings(tableDifferences, inverseTableDiff);
    const fromTableIndexes = fromTable.getIndexes();
    const toTableIndexes = toTable.getIndexes();
    // See if all the indexes in "from" table exist in "to" table
    for (const index of Object.values(toTableIndexes)) {
      if ((index.primary && fromTableIndexes.find(i => i.primary)) || fromTable.hasIndex(index.keyName)) {
        continue;
      }
      tableDifferences.addedIndexes[index.keyName] = index;
      this.log(`index ${index.keyName} added to table ${tableDifferences.name}`, { index });
      changes++;
    }
    // See if there are any removed indexes in "to" table
    for (const index of fromTableIndexes) {
      // See if index is removed in "to" table.
      if ((index.primary && !toTable.hasPrimaryKey()) || (!index.primary && !toTable.hasIndex(index.keyName))) {
        tableDifferences.removedIndexes[index.keyName] = index;
        this.log(`index ${index.keyName} removed from table ${tableDifferences.name}`);
        changes++;
        continue;
      }
      // See if index has changed in "to" table.
      const toTableIndex = index.primary ? toTable.getPrimaryKey() : toTable.getIndex(index.keyName);
      if (!this.diffIndex(index, toTableIndex)) {
        continue;
      }
      tableDifferences.changedIndexes[index.keyName] = toTableIndex;
      this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
        fromTableIndex: index,
        toTableIndex,
      });
      changes++;
    }
    this.detectIndexRenamings(tableDifferences);
    const fromTableChecks = fromTable.getChecks();
    const toTableChecks = toTable.getChecks();
    // See if all the checks in "from" table exist in "to" table
    for (const check of toTableChecks) {
      if (fromTable.hasCheck(check.name)) {
        continue;
      }
      tableDifferences.addedChecks[check.name] = check;
      this.log(`check constraint ${check.name} added to table ${tableDifferences.name}`, { check });
      changes++;
    }
    // See if there are any removed checks in "to" table
    for (const check of fromTableChecks) {
      if (!toTable.hasCheck(check.name)) {
        tableDifferences.removedChecks[check.name] = check;
        this.log(`check constraint ${check.name} removed from table ${tableDifferences.name}`);
        changes++;
        continue;
      }
      // See if check has changed in "to" table
      const toTableCheck = toTable.getCheck(check.name);
      const toColumn = toTable.getColumn(check.columnName);
      const fromColumn = fromTable.getColumn(check.columnName);
      if (!this.diffExpression(check.expression, toTableCheck.expression)) {
        continue;
      }
      if (
        fromColumn?.enumItems &&
        toColumn?.enumItems &&
        !this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)
      ) {
        continue;
      }
      this.log(`check constraint ${check.name} changed in table ${tableDifferences.name}`, {
        fromTableCheck: check,
        toTableCheck,
      });
      tableDifferences.changedChecks[check.name] = toTableCheck;
      changes++;
    }
    const fromForeignKeys = { ...fromTable.getForeignKeys() };
    const toForeignKeys = { ...toTable.getForeignKeys() };
    for (const fromConstraint of Object.values(fromForeignKeys)) {
      for (const toConstraint of Object.values(toForeignKeys)) {
        if (!this.diffForeignKey(fromConstraint, toConstraint, tableDifferences)) {
          delete fromForeignKeys[fromConstraint.constraintName];
          delete toForeignKeys[toConstraint.constraintName];
        } else if (fromConstraint.constraintName.toLowerCase() === toConstraint.constraintName.toLowerCase()) {
          this.log(`FK constraint ${fromConstraint.constraintName} changed in table ${tableDifferences.name}`, {
            fromConstraint,
            toConstraint,
          });
          tableDifferences.changedForeignKeys[toConstraint.constraintName] = toConstraint;
          changes++;
          delete fromForeignKeys[fromConstraint.constraintName];
          delete toForeignKeys[toConstraint.constraintName];
        }
      }
    }
    for (const fromConstraint of Object.values(fromForeignKeys)) {
      tableDifferences.removedForeignKeys[fromConstraint.constraintName] = fromConstraint;
      this.log(`FK constraint ${fromConstraint.constraintName} removed from table ${tableDifferences.name}`);
      changes++;
    }
    for (const toConstraint of Object.values(toForeignKeys)) {
      tableDifferences.addedForeignKeys[toConstraint.constraintName] = toConstraint;
      this.log(`FK constraint ${toConstraint.constraintName} added to table ${tableDifferences.name}`, {
        constraint: toConstraint,
      });
      changes++;
    }
    return changes ? tableDifferences : false;
  }
  /**
   * Try to find columns that only changed their name, rename operations maybe cheaper than add/drop
   * however ambiguities between different possibilities should not lead to renaming at all.
   */
  detectColumnRenamings(tableDifferences, inverseTableDiff) {
    const renameCandidates = {};
    const oldFKs = Object.values(tableDifferences.fromTable.getForeignKeys());
    const newFKs = Object.values(tableDifferences.toTable.getForeignKeys());
    for (const addedColumn of Object.values(tableDifferences.addedColumns)) {
      for (const removedColumn of Object.values(tableDifferences.removedColumns)) {
        const diff = this.diffColumn(addedColumn, removedColumn, tableDifferences.fromTable);
        if (diff.size !== 0) {
          continue;
        }
        const wasFK = oldFKs.some(fk => fk.columnNames.includes(removedColumn.name));
        const isFK = newFKs.some(fk => fk.columnNames.includes(addedColumn.name));
        if (wasFK !== isFK) {
          continue;
        }
        const renamedColumn = inverseTableDiff?.renamedColumns[addedColumn.name];
        if (renamedColumn && renamedColumn?.name !== removedColumn.name) {
          continue;
        }
        renameCandidates[addedColumn.name] = renameCandidates[addedColumn.name] ?? [];
        renameCandidates[addedColumn.name].push([removedColumn, addedColumn]);
      }
    }
    for (const candidateColumns of Object.values(renameCandidates)) {
      if (candidateColumns.length !== 1) {
        continue;
      }
      const [removedColumn, addedColumn] = candidateColumns[0];
      const removedColumnName = removedColumn.name;
      const addedColumnName = addedColumn.name;
      /* v8 ignore next */
      if (tableDifferences.renamedColumns[removedColumnName]) {
        continue;
      }
      tableDifferences.renamedColumns[removedColumnName] = addedColumn;
      delete tableDifferences.addedColumns[addedColumnName];
      delete tableDifferences.removedColumns[removedColumnName];
      this.log(`renamed column detected in table ${tableDifferences.name}`, {
        old: removedColumnName,
        new: addedColumnName,
      });
    }
  }
  /**
   * Try to find indexes that only changed their name, rename operations maybe cheaper than add/drop
   * however ambiguities between different possibilities should not lead to renaming at all.
   */
  detectIndexRenamings(tableDifferences) {
    const renameCandidates = {};
    // Gather possible rename candidates by comparing each added and removed index based on semantics.
    for (const addedIndex of Object.values(tableDifferences.addedIndexes)) {
      for (const removedIndex of Object.values(tableDifferences.removedIndexes)) {
        if (this.diffIndex(addedIndex, removedIndex)) {
          continue;
        }
        renameCandidates[addedIndex.keyName] = renameCandidates[addedIndex.keyName] ?? [];
        renameCandidates[addedIndex.keyName].push([removedIndex, addedIndex]);
      }
    }
    for (const candidateIndexes of Object.values(renameCandidates)) {
      // If the current rename candidate contains exactly one semantically equal index, we can safely rename it.
      // Otherwise it is unclear if a rename action is really intended, therefore we let those ambiguous indexes be added/dropped.
      if (candidateIndexes.length !== 1) {
        continue;
      }
      const [removedIndex, addedIndex] = candidateIndexes[0];
      const removedIndexName = removedIndex.keyName;
      const addedIndexName = addedIndex.keyName;
      if (tableDifferences.renamedIndexes[removedIndexName]) {
        continue;
      }
      tableDifferences.renamedIndexes[removedIndexName] = addedIndex;
      delete tableDifferences.addedIndexes[addedIndexName];
      delete tableDifferences.removedIndexes[removedIndexName];
      this.log(`renamed index detected in table ${tableDifferences.name}`, {
        old: removedIndexName,
        new: addedIndexName,
      });
    }
  }
  diffForeignKey(key1, key2, tableDifferences) {
    if (key1.columnNames.join('~').toLowerCase() !== key2.columnNames.join('~').toLowerCase()) {
      return true;
    }
    if (key1.referencedColumnNames.join('~').toLowerCase() !== key2.referencedColumnNames.join('~').toLowerCase()) {
      return true;
    }
    if (key1.constraintName !== key2.constraintName) {
      return true;
    }
    if (key1.referencedTableName !== key2.referencedTableName) {
      return true;
    }
    if (key1.deferMode !== key2.deferMode) {
      return true;
    }
    if (key1.localTableName === key1.referencedTableName && !this.#platform.supportsMultipleCascadePaths()) {
      return false;
    }
    if (key1.columnNames.some(col => tableDifferences.changedColumns[col]?.changedProperties.has('type'))) {
      return true;
    }
    const defaultRule = ['restrict', 'no action'];
    const rule = (key, method) => {
      return (key[method] ?? defaultRule[0]).toLowerCase().replace(defaultRule[1], defaultRule[0]).replace(/"/g, '');
    };
    const compare = method => rule(key1, method) === rule(key2, method);
    // Skip updateRule comparison for platforms that don't support ON UPDATE (e.g., Oracle)
    const updateRuleDiffers = this.#platform.supportsOnUpdate() && !compare('updateRule');
    return updateRuleDiffers || !compare('deleteRule');
  }
  /**
   * Returns the difference between the columns
   */
  diffColumn(fromColumn, toColumn, fromTable, logging) {
    const changedProperties = new Set();
    const fromProp = this.mapColumnToProperty({ ...fromColumn, autoincrement: false });
    const toProp = this.mapColumnToProperty({ ...toColumn, autoincrement: false });
    const fromColumnType = this.#platform.normalizeColumnType(
      fromColumn.mappedType.getColumnType(fromProp, this.#platform).toLowerCase(),
      fromProp,
    );
    const fromNativeEnum =
      fromTable.nativeEnums[fromColumnType] ??
      Object.values(fromTable.nativeEnums).find(e => e.name === fromColumnType && e.schema !== '*');
    let toColumnType = this.#platform.normalizeColumnType(
      toColumn.mappedType.getColumnType(toProp, this.#platform).toLowerCase(),
      toProp,
    );
    const log = (msg, params) => {
      if (logging) {
        const copy = Utils.copy(params);
        Utils.dropUndefinedProperties(copy);
        this.log(msg, copy);
      }
    };
    if (
      fromColumnType !== toColumnType &&
      (!fromNativeEnum || `${fromNativeEnum.schema}.${fromNativeEnum.name}` !== toColumnType) &&
      !(fromColumn.ignoreSchemaChanges?.includes('type') || toColumn.ignoreSchemaChanges?.includes('type')) &&
      !fromColumn.generated &&
      !toColumn.generated
    ) {
      if (
        !toColumnType.includes('.') &&
        fromTable.schema &&
        fromTable.schema !== this.#platform.getDefaultSchemaName()
      ) {
        toColumnType = `${fromTable.schema}.${toColumnType}`;
      }
      if (fromColumnType !== toColumnType) {
        log(`'type' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumnType, toColumnType });
        changedProperties.add('type');
      }
    }
    if (!!fromColumn.nullable !== !!toColumn.nullable && !fromColumn.generated && !toColumn.generated) {
      log(`'nullable' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('nullable');
    }
    if (this.diffExpression(fromColumn.generated, toColumn.generated)) {
      log(`'generated' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('generated');
    }
    if (!!fromColumn.autoincrement !== !!toColumn.autoincrement) {
      log(`'autoincrement' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('autoincrement');
    }
    if (!!fromColumn.unsigned !== !!toColumn.unsigned && this.#platform.supportsUnsigned()) {
      log(`'unsigned' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('unsigned');
    }
    if (
      !(fromColumn.ignoreSchemaChanges?.includes('default') || toColumn.ignoreSchemaChanges?.includes('default')) &&
      !this.hasSameDefaultValue(fromColumn, toColumn)
    ) {
      log(`'default' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('default');
    }
    if (this.diffComment(fromColumn.comment, toColumn.comment)) {
      log(`'comment' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('comment');
    }
    if (
      !(fromColumn.mappedType instanceof ArrayType) &&
      !(toColumn.mappedType instanceof ArrayType) &&
      this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)
    ) {
      log(`'enumItems' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('enumItems');
    }
    if (
      (fromColumn.extra || '').toLowerCase() !== (toColumn.extra || '').toLowerCase() &&
      !(fromColumn.ignoreSchemaChanges?.includes('extra') || toColumn.ignoreSchemaChanges?.includes('extra'))
    ) {
      log(`'extra' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
      changedProperties.add('extra');
    }
    return changedProperties;
  }
  diffEnumItems(items1 = [], items2 = []) {
    return items1.length !== items2.length || items1.some((v, i) => v !== items2[i]);
  }
  diffComment(comment1, comment2) {
    // A null value and an empty string are actually equal for a comment so they should not trigger a change.
    // eslint-disable-next-line eqeqeq
    return comment1 != comment2 && !(comment1 == null && comment2 === '') && !(comment2 == null && comment1 === '');
  }
  /**
   * Finds the difference between the indexes index1 and index2.
   * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
   */
  diffIndex(index1, index2) {
    // if one of them is a custom expression or full text index, compare only by name
    if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
      return index1.keyName !== index2.keyName;
    }
    return !this.isIndexFulfilledBy(index1, index2) || !this.isIndexFulfilledBy(index2, index1);
  }
  /**
   * Checks if the other index already fulfills all the indexing and constraint needs of the current one.
   */
  isIndexFulfilledBy(index1, index2) {
    // allow the other index to be equally large only. It being larger is an option but it creates a problem with scenarios of the kind PRIMARY KEY(foo,bar) UNIQUE(foo)
    if (index1.columnNames.length !== index2.columnNames.length) {
      return false;
    }
    function spansColumns() {
      for (let i = 0; i < index1.columnNames.length; i++) {
        if (index1.columnNames[i] === index2.columnNames[i]) {
          continue;
        }
        return false;
      }
      return true;
    }
    // Check if columns are the same, and even in the same order
    if (!spansColumns()) {
      return false;
    }
    // Compare advanced column options (sort order, nulls, length, collation)
    if (!this.compareIndexColumns(index1, index2)) {
      return false;
    }
    // Compare INCLUDE columns for covering indexes
    if (!this.compareArrays(index1.include, index2.include)) {
      return false;
    }
    // Compare fill factor
    if (index1.fillFactor !== index2.fillFactor) {
      return false;
    }
    // Compare invisible flag
    if (!!index1.invisible !== !!index2.invisible) {
      return false;
    }
    // Compare disabled flag
    if (!!index1.disabled !== !!index2.disabled) {
      return false;
    }
    // Compare clustered flag
    if (!!index1.clustered !== !!index2.clustered) {
      return false;
    }
    if (!index1.unique && !index1.primary) {
      // this is a special case: If the current key is neither primary or unique, any unique or
      // primary key will always have the same effect for the index and there cannot be any constraint
      // overlaps. This means a primary or unique index can always fulfill the requirements of just an
      // index that has no constraints.
      return true;
    }
    if (this.#platform.supportsDeferredUniqueConstraints() && index1.deferMode !== index2.deferMode) {
      return false;
    }
    return index1.primary === index2.primary && index1.unique === index2.unique;
  }
  /**
   * Compare advanced column options between two indexes.
   */
  compareIndexColumns(index1, index2) {
    const cols1 = index1.columns ?? [];
    const cols2 = index2.columns ?? [];
    // If neither has column options, they match
    if (cols1.length === 0 && cols2.length === 0) {
      return true;
    }
    // If only one has column options, they don't match
    if (cols1.length !== cols2.length) {
      return false;
    }
    // Compare each column's options
    // Note: We don't check c1.name !== c2.name because the indexes already have matching columnNames
    // and the columns array is derived from those same column names
    for (let i = 0; i < cols1.length; i++) {
      const c1 = cols1[i];
      const c2 = cols2[i];
      const sort1 = c1.sort?.toUpperCase() ?? 'ASC';
      const sort2 = c2.sort?.toUpperCase() ?? 'ASC';
      if (sort1 !== sort2) {
        return false;
      }
      const defaultNulls = s => (s === 'DESC' ? 'FIRST' : 'LAST');
      const nulls1 = c1.nulls?.toUpperCase() ?? defaultNulls(sort1);
      const nulls2 = c2.nulls?.toUpperCase() ?? defaultNulls(sort2);
      if (nulls1 !== nulls2) {
        return false;
      }
      if (c1.length !== c2.length) {
        return false;
      }
      if (c1.collation !== c2.collation) {
        return false;
      }
    }
    return true;
  }
  /**
   * Compare two arrays for equality (order matters).
   */
  compareArrays(arr1, arr2) {
    if (!arr1 && !arr2) {
      return true;
    }
    if (!arr1 || !arr2 || arr1.length !== arr2.length) {
      return false;
    }
    return arr1.every((val, i) => val === arr2[i]);
  }
  diffExpression(expr1, expr2) {
    // expressions like check constraints might be normalized by the driver,
    // e.g. quotes might be added (https://github.com/mikro-orm/mikro-orm/issues/3827)
    const simplify = str => {
      return (
        str
          ?.replace(/_\w+'(.*?)'/g, '$1')
          .replace(/in\s*\((.*?)\)/gi, '= any (array[$1])')
          // MySQL normalizes count(*) to count(0)
          .replace(/\bcount\s*\(\s*0\s*\)/gi, 'count(*)')
          // Remove quotes first so we can process identifiers
          .replace(/['"`]/g, '')
          // MySQL adds table/alias prefixes to columns (e.g., a.name or table_name.column vs just column)
          // Strip these prefixes - match word.word patterns and keep only the last part
          .replace(/\b\w+\.(\w+)/g, '$1')
          // Normalize JOIN syntax: inner join -> join (equivalent in SQL)
          .replace(/\binner\s+join\b/gi, 'join')
          // Remove redundant column aliases like `title AS title` -> `title`
          .replace(/\b(\w+)\s+as\s+\1\b/gi, '$1')
          // Remove AS keyword (optional in SQL, MySQL may add/remove it)
          .replace(/\bas\b/gi, '')
          // Remove remaining special chars, parentheses, type casts, asterisks, and normalize whitespace
          .replace(/[()\n[\]*]|::\w+| +/g, '')
          .replace(/anyarray\[(.*)]/gi, '$1')
          .toLowerCase()
          // PostgreSQL adds default aliases to aggregate functions (e.g., count(*) AS count)
          // After removing AS and whitespace, this results in duplicate adjacent words
          // Remove these duplicates: "countcount" -> "count", "minmin" -> "min"
          // Use lookahead to match repeated patterns of 3+ chars (avoid false positives on short sequences)
          .replace(/(\w{3,})\1/g, '$1')
          // Remove trailing semicolon (PostgreSQL adds it to view definitions)
          .replace(/;$/, '')
      );
    };
    return simplify(expr1) !== simplify(expr2);
  }
  parseJsonDefault(defaultValue) {
    /* v8 ignore next */
    if (!defaultValue) {
      return null;
    }
    const val = defaultValue.replace(/^(_\w+\\)?'(.*?)\\?'$/, '$2').replace(/^\(?'(.*?)'\)?$/, '$1');
    return parseJsonSafe(val);
  }
  hasSameDefaultValue(from, to) {
    if (
      from.default == null ||
      from.default.toString().toLowerCase() === 'null' ||
      from.default.toString().startsWith('nextval(')
    ) {
      return to.default == null || to.default.toLowerCase() === 'null';
    }
    if (to.mappedType instanceof BooleanType) {
      const defaultValueFrom = !['0', 'false', 'f', 'n', 'no', 'off'].includes('' + from.default);
      const defaultValueTo = !['0', 'false', 'f', 'n', 'no', 'off'].includes('' + to.default);
      return defaultValueFrom === defaultValueTo;
    }
    if (to.mappedType instanceof JsonType) {
      const defaultValueFrom = this.parseJsonDefault(from.default);
      const defaultValueTo = this.parseJsonDefault(to.default);
      return Utils.equals(defaultValueFrom, defaultValueTo);
    }
    if (to.mappedType instanceof DateTimeType && from.default && to.default) {
      // normalize now/current_timestamp defaults, also remove `()` from the end of default expression
      const defaultValueFrom = from.default.toLowerCase().replace('current_timestamp', 'now').replace(/\(\)$/, '');
      const defaultValueTo = to.default.toLowerCase().replace('current_timestamp', 'now').replace(/\(\)$/, '');
      return defaultValueFrom === defaultValueTo;
    }
    if (from.default && to.default) {
      return from.default.toString().toLowerCase() === to.default.toString().toLowerCase();
    }
    if (['', this.#helper.getDefaultEmptyString()].includes(to.default) && from.default != null) {
      return ['', this.#helper.getDefaultEmptyString()].includes(from.default.toString());
    }
    // eslint-disable-next-line eqeqeq
    return from.default == to.default; // == intentionally
  }
  mapColumnToProperty(column) {
    const length = /\w+\((\d+)\)/.exec(column.type);
    const match = /\w+\((\d+), ?(\d+)\)/.exec(column.type);
    return {
      fieldNames: [column.name],
      columnTypes: [column.type],
      items: column.enumItems,
      ...column,
      length: length ? +length[1] : column.length,
      precision: match ? +match[1] : column.precision,
      scale: match ? +match[2] : column.scale,
    };
  }
  log(message, params) {
    if (params) {
      message += ' ' + inspect(params);
    }
    this.#logger.log('schema', message);
  }
}
