import { CommitOrderCalculator, TableNotFoundException, Utils } from '@mikro-orm/core';
import { AbstractSchemaGenerator } from '@mikro-orm/core/schema';
import { DatabaseSchema } from './DatabaseSchema.js';
import { SchemaComparator } from './SchemaComparator.js';
/** Generates and manages SQL database schemas based on entity metadata. Supports create, update, and drop operations. */
export class SqlSchemaGenerator extends AbstractSchemaGenerator {
  helper = this.platform.getSchemaHelper();
  options = this.config.get('schemaGenerator');
  lastEnsuredDatabase;
  static register(orm) {
    orm.config.registerExtension('@mikro-orm/schema-generator', () => new SqlSchemaGenerator(orm.em));
  }
  async create(options) {
    await this.ensureDatabase();
    const sql = await this.getCreateSchemaSQL(options);
    await this.execute(sql);
  }
  /**
   * Returns true if the database was created.
   */
  async ensureDatabase(options) {
    await this.connection.ensureConnection();
    const dbName = this.config.get('dbName');
    if (this.lastEnsuredDatabase === dbName && !options?.forceCheck) {
      return true;
    }
    const exists = await this.helper.databaseExists(this.connection, dbName);
    this.lastEnsuredDatabase = dbName;
    if (!exists) {
      const managementDbName = this.helper.getManagementDbName();
      if (managementDbName) {
        this.config.set('dbName', managementDbName);
        await this.driver.reconnect({ skipOnConnect: true });
        await this.createDatabase(dbName, { skipOnConnect: true });
      }
      if (options?.create) {
        await this.create(options);
      }
      return true;
    }
    /* v8 ignore next */
    if (options?.clear) {
      await this.clear({ ...options, clearIdentityMap: false });
    }
    return false;
  }
  getTargetSchema(schema) {
    const metadata = this.getOrderedMetadata(schema);
    const schemaName = schema ?? this.config.get('schema') ?? this.platform.getDefaultSchemaName();
    return DatabaseSchema.fromMetadata(metadata, this.platform, this.config, schemaName, this.em);
  }
  getOrderedMetadata(schema) {
    const metadata = super.getOrderedMetadata(schema);
    // Filter out skipped tables
    return metadata.filter(meta => {
      const tableName = meta.tableName;
      const tableSchema = meta.schema ?? schema ?? this.config.get('schema');
      return !this.isTableSkipped(tableName, tableSchema);
    });
  }
  async getCreateSchemaSQL(options = {}) {
    const toSchema = this.getTargetSchema(options.schema);
    const ret = [];
    for (const namespace of toSchema.getNamespaces()) {
      if (namespace === this.platform.getDefaultSchemaName()) {
        continue;
      }
      const sql = this.helper.getCreateNamespaceSQL(namespace);
      this.append(ret, sql);
    }
    if (this.platform.supportsNativeEnums()) {
      const created = [];
      for (const [enumName, enumOptions] of Object.entries(toSchema.getNativeEnums())) {
        /* v8 ignore next */
        if (created.includes(enumName)) {
          continue;
        }
        created.push(enumName);
        const sql = this.helper.getCreateNativeEnumSQL(
          enumOptions.name,
          enumOptions.items,
          this.getSchemaName(enumOptions, options),
        );
        this.append(ret, sql);
      }
    }
    for (const table of toSchema.getTables()) {
      this.append(ret, this.helper.createTable(table), true);
    }
    if (this.helper.supportsSchemaConstraints()) {
      for (const table of toSchema.getTables()) {
        const fks = Object.values(table.getForeignKeys()).map(fk => this.helper.createForeignKey(table, fk));
        this.append(ret, fks, true);
      }
    }
    // Create views after tables (views may depend on tables)
    // Sort views by dependencies (views depending on other views come later)
    const sortedViews = this.sortViewsByDependencies(toSchema.getViews());
    for (const view of sortedViews) {
      if (view.materialized) {
        this.append(
          ret,
          this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true),
        );
      } else {
        this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
      }
    }
    return this.wrapSchema(ret, options);
  }
  async drop(options = {}) {
    if (options.dropDb) {
      const name = this.config.get('dbName');
      return this.dropDatabase(name);
    }
    const sql = await this.getDropSchemaSQL(options);
    await this.execute(sql);
  }
  async createNamespace(name) {
    const sql = this.helper.getCreateNamespaceSQL(name);
    await this.execute(sql);
  }
  async dropNamespace(name) {
    const sql = this.helper.getDropNamespaceSQL(name);
    await this.execute(sql);
  }
  async clear(options) {
    // truncate by default, so no value is considered as true
    /* v8 ignore next */
    if (options?.truncate === false) {
      return super.clear(options);
    }
    if (this.options.disableForeignKeysForClear) {
      await this.execute(this.helper.disableForeignKeysSQL());
    }
    const schema = options?.schema ?? this.config.get('schema', this.platform.getDefaultSchemaName());
    for (const meta of this.getOrderedMetadata(schema).reverse()) {
      try {
        await this.driver
          .createQueryBuilder(meta.class, this.em?.getTransactionContext(), 'write', false)
          .withSchema(schema)
          .truncate()
          .execute();
      } catch (e) {
        if (this.platform.getExceptionConverter().convertException(e) instanceof TableNotFoundException) {
          continue;
        }
        throw e;
      }
    }
    if (this.options.disableForeignKeysForClear) {
      await this.execute(this.helper.enableForeignKeysSQL());
    }
    if (options?.clearIdentityMap ?? true) {
      this.clearIdentityMap();
    }
  }
  async getDropSchemaSQL(options = {}) {
    await this.ensureDatabase();
    const metadata = this.getOrderedMetadata(options.schema).reverse();
    const schemas = this.getTargetSchema(options.schema).getNamespaces();
    const schema = await DatabaseSchema.create(
      this.connection,
      this.platform,
      this.config,
      options.schema,
      schemas,
      undefined,
      this.options.skipTables,
      this.options.skipViews,
    );
    const ret = [];
    // Drop views first (views may depend on tables)
    // Drop in reverse dependency order (dependent views first)
    const targetSchema = this.getTargetSchema(options.schema);
    const sortedViews = this.sortViewsByDependencies(targetSchema.getViews()).reverse();
    for (const view of sortedViews) {
      if (view.materialized) {
        this.append(ret, this.helper.dropMaterializedViewIfExists(view.name, view.schema));
      } else {
        this.append(ret, this.helper.dropViewIfExists(view.name, view.schema));
      }
    }
    // remove FKs explicitly if we can't use a cascading statement and we don't disable FK checks (we need this for circular relations)
    for (const meta of metadata) {
      if (!this.platform.usesCascadeStatement() && (!this.options.disableForeignKeys || options.dropForeignKeys)) {
        const table = schema.getTable(meta.tableName);
        if (!table) {
          continue;
        }
        const foreignKeys = Object.values(table.getForeignKeys());
        for (const fk of foreignKeys) {
          this.append(ret, this.helper.dropForeignKey(table.getShortestName(), fk.constraintName));
        }
      }
    }
    for (const meta of metadata) {
      this.append(ret, this.helper.dropTableIfExists(meta.tableName, this.getSchemaName(meta, options)));
    }
    if (this.platform.supportsNativeEnums()) {
      for (const columnName of Object.keys(schema.getNativeEnums())) {
        const sql = this.helper.getDropNativeEnumSQL(columnName, options.schema ?? this.config.get('schema'));
        this.append(ret, sql);
      }
    }
    if (options.dropMigrationsTable) {
      this.append(
        ret,
        this.helper.dropTableIfExists(this.config.get('migrations').tableName, this.config.get('schema')),
      );
    }
    return this.wrapSchema(ret, options);
  }
  getSchemaName(meta, options) {
    const schemaName = options.schema ?? this.config.get('schema');
    /* v8 ignore next */
    const resolvedName = meta.schema && meta.schema === '*' ? schemaName : (meta.schema ?? schemaName);
    // skip default schema name
    if (resolvedName === this.platform.getDefaultSchemaName()) {
      return undefined;
    }
    return resolvedName;
  }
  async update(options = {}) {
    const sql = await this.getUpdateSchemaSQL(options);
    await this.execute(sql);
  }
  async getUpdateSchemaSQL(options = {}) {
    await this.ensureDatabase();
    const { fromSchema, toSchema } = await this.prepareSchemaForComparison(options);
    const comparator = new SchemaComparator(this.platform);
    const diffUp = comparator.compare(fromSchema, toSchema);
    return this.diffToSQL(diffUp, options);
  }
  async getUpdateSchemaMigrationSQL(options = {}) {
    if (!options.fromSchema) {
      await this.ensureDatabase();
    }
    const { fromSchema, toSchema } = await this.prepareSchemaForComparison(options);
    const comparator = new SchemaComparator(this.platform);
    const diffUp = comparator.compare(fromSchema, toSchema);
    const diffDown = comparator.compare(toSchema, fromSchema, diffUp);
    return {
      up: this.diffToSQL(diffUp, options),
      down: this.platform.supportsDownMigrations() ? this.diffToSQL(diffDown, options) : '',
    };
  }
  async prepareSchemaForComparison(options) {
    options.safe ??= false;
    options.dropTables ??= true;
    const toSchema = this.getTargetSchema(options.schema);
    const schemas = toSchema.getNamespaces();
    const fromSchema =
      options.fromSchema ??
      (await DatabaseSchema.create(
        this.connection,
        this.platform,
        this.config,
        options.schema,
        schemas,
        undefined,
        this.options.skipTables,
        this.options.skipViews,
      ));
    const wildcardSchemaTables = [...this.metadata.getAll().values()]
      .filter(meta => meta.schema === '*')
      .map(meta => meta.tableName);
    fromSchema.prune(options.schema, wildcardSchemaTables);
    toSchema.prune(options.schema, wildcardSchemaTables);
    return { fromSchema, toSchema };
  }
  diffToSQL(schemaDiff, options) {
    const ret = [];
    globalThis.idx = 0;
    if (this.platform.supportsSchemas()) {
      for (const newNamespace of schemaDiff.newNamespaces) {
        const sql = this.helper.getCreateNamespaceSQL(newNamespace);
        this.append(ret, sql);
      }
    }
    if (this.platform.supportsNativeEnums()) {
      for (const newNativeEnum of schemaDiff.newNativeEnums) {
        const sql = this.helper.getCreateNativeEnumSQL(
          newNativeEnum.name,
          newNativeEnum.items,
          this.getSchemaName(newNativeEnum, options),
        );
        this.append(ret, sql);
      }
    }
    // Drop removed and changed views first (before modifying tables they may depend on)
    // Drop in reverse dependency order (dependent views first)
    if (options.dropTables && !options.safe) {
      const sortedRemovedViews = this.sortViewsByDependencies(Object.values(schemaDiff.removedViews)).reverse();
      for (const view of sortedRemovedViews) {
        if (view.materialized) {
          this.append(ret, this.helper.dropMaterializedViewIfExists(view.name, view.schema));
        } else {
          this.append(ret, this.helper.dropViewIfExists(view.name, view.schema));
        }
      }
    }
    // Drop changed views (they will be recreated after table changes)
    // Also in reverse dependency order
    const changedViewsFrom = Object.values(schemaDiff.changedViews).map(v => v.from);
    const sortedChangedViewsFrom = this.sortViewsByDependencies(changedViewsFrom).reverse();
    for (const view of sortedChangedViewsFrom) {
      if (view.materialized) {
        this.append(ret, this.helper.dropMaterializedViewIfExists(view.name, view.schema));
      } else {
        this.append(ret, this.helper.dropViewIfExists(view.name, view.schema));
      }
    }
    if (!options.safe && this.options.createForeignKeyConstraints) {
      for (const orphanedForeignKey of schemaDiff.orphanedForeignKeys) {
        const [schemaName, tableName] = this.helper.splitTableName(orphanedForeignKey.localTableName, true);
        /* v8 ignore next */
        const name = (schemaName ? schemaName + '.' : '') + tableName;
        this.append(ret, this.helper.dropForeignKey(name, orphanedForeignKey.constraintName));
      }
      if (schemaDiff.orphanedForeignKeys.length > 0) {
        ret.push('');
      }
    }
    for (const newTable of Object.values(schemaDiff.newTables)) {
      this.append(ret, this.helper.createTable(newTable, true), true);
    }
    if (this.helper.supportsSchemaConstraints()) {
      for (const newTable of Object.values(schemaDiff.newTables)) {
        const sql = [];
        if (this.options.createForeignKeyConstraints) {
          const fks = Object.values(newTable.getForeignKeys()).map(fk => this.helper.createForeignKey(newTable, fk));
          this.append(sql, fks);
        }
        for (const check of newTable.getChecks()) {
          this.append(sql, this.helper.createCheck(newTable, check));
        }
        this.append(ret, sql, true);
      }
    }
    if (options.dropTables && !options.safe) {
      for (const table of Object.values(schemaDiff.removedTables)) {
        this.append(ret, this.helper.dropTableIfExists(table.name, table.schema));
      }
      if (Utils.hasObjectKeys(schemaDiff.removedTables)) {
        ret.push('');
      }
    }
    for (const changedTable of Object.values(schemaDiff.changedTables)) {
      this.append(ret, this.preAlterTable(changedTable, options.safe), true);
    }
    for (const changedTable of Object.values(schemaDiff.changedTables)) {
      this.append(ret, this.helper.alterTable(changedTable, options.safe), true);
    }
    for (const changedTable of Object.values(schemaDiff.changedTables)) {
      this.append(ret, this.helper.getPostAlterTable(changedTable, options.safe), true);
    }
    if (!options.safe && this.platform.supportsNativeEnums()) {
      for (const removedNativeEnum of schemaDiff.removedNativeEnums) {
        this.append(ret, this.helper.getDropNativeEnumSQL(removedNativeEnum.name, removedNativeEnum.schema));
      }
    }
    if (options.dropTables && !options.safe) {
      for (const removedNamespace of schemaDiff.removedNamespaces) {
        const sql = this.helper.getDropNamespaceSQL(removedNamespace);
        this.append(ret, sql);
      }
    }
    // Create new views after all table changes are done
    // Sort views by dependencies (views depending on other views come later)
    const sortedNewViews = this.sortViewsByDependencies(Object.values(schemaDiff.newViews));
    for (const view of sortedNewViews) {
      if (view.materialized) {
        this.append(
          ret,
          this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true),
        );
      } else {
        this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
      }
    }
    // Recreate changed views (also sorted by dependencies)
    const changedViews = Object.values(schemaDiff.changedViews).map(v => v.to);
    const sortedChangedViews = this.sortViewsByDependencies(changedViews);
    for (const view of sortedChangedViews) {
      if (view.materialized) {
        this.append(
          ret,
          this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true),
        );
      } else {
        this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
      }
    }
    return this.wrapSchema(ret, options);
  }
  /**
   * We need to drop foreign keys first for all tables to allow dropping PK constraints.
   */
  preAlterTable(diff, safe) {
    const ret = [];
    this.append(ret, this.helper.getPreAlterTable(diff, safe));
    for (const foreignKey of Object.values(diff.removedForeignKeys)) {
      ret.push(this.helper.dropForeignKey(diff.toTable.getShortestName(), foreignKey.constraintName));
    }
    for (const foreignKey of Object.values(diff.changedForeignKeys)) {
      ret.push(this.helper.dropForeignKey(diff.toTable.getShortestName(), foreignKey.constraintName));
    }
    return ret;
  }
  /**
   * creates new database and connects to it
   */
  async createDatabase(name, options) {
    name ??= this.config.get('dbName');
    const sql = this.helper.getCreateDatabaseSQL(name);
    if (sql) {
      await this.execute(sql);
    }
    this.config.set('dbName', name);
    await this.driver.reconnect(options);
  }
  async dropDatabase(name) {
    name ??= this.config.get('dbName');
    this.config.set('dbName', this.helper.getManagementDbName());
    await this.driver.reconnect();
    await this.execute(this.helper.getDropDatabaseSQL(name));
    this.config.set('dbName', name);
  }
  async execute(sql, options = {}) {
    options.wrap ??= false;
    const lines = this.wrapSchema(sql, options).split('\n');
    const groups = [];
    let i = 0;
    for (const line of lines) {
      if (line.trim() === '') {
        if (groups[i]?.length > 0) {
          i++;
        }
        continue;
      }
      groups[i] ??= [];
      groups[i].push(line.trim());
    }
    if (groups.length === 0) {
      return;
    }
    if (this.platform.supportsMultipleStatements()) {
      for (const group of groups) {
        const query = group.join('\n');
        await this.driver.execute(query);
      }
      return;
    }
    const statements = groups.flatMap(group => {
      return group
        .join('\n')
        .split(';\n')
        .map(s => s.trim())
        .filter(s => s);
    });
    await Utils.runSerial(statements, stmt => this.driver.execute(stmt));
  }
  async dropTableIfExists(name, schema) {
    const sql = this.helper.dropTableIfExists(name, schema);
    return this.execute(sql);
  }
  wrapSchema(sql, options) {
    const array = Utils.asArray(sql);
    if (array.length === 0) {
      return '';
    }
    if (array[array.length - 1] === '') {
      array.pop();
    }
    if (options.wrap === false) {
      return array.join('\n') + '\n';
    }
    let ret = this.helper.getSchemaBeginning(this.config.get('charset'), this.options.disableForeignKeys);
    ret += array.join('\n') + '\n';
    ret += this.helper.getSchemaEnd(this.options.disableForeignKeys);
    return ret;
  }
  append(array, sql, pad) {
    return this.helper.append(array, sql, pad);
  }
  matchName(name, nameToMatch) {
    return typeof nameToMatch === 'string'
      ? name.toLocaleLowerCase() === nameToMatch.toLocaleLowerCase()
      : nameToMatch.test(name);
  }
  isTableSkipped(tableName, schemaName) {
    const skipTables = this.options.skipTables;
    if (!skipTables || skipTables.length === 0) {
      return false;
    }
    const fullTableName = schemaName ? `${schemaName}.${tableName}` : tableName;
    return skipTables.some(pattern => this.matchName(tableName, pattern) || this.matchName(fullTableName, pattern));
  }
  /**
   * Sorts views by their dependencies so that views depending on other views are created after their dependencies.
   * Uses topological sort based on view definition string matching.
   */
  sortViewsByDependencies(views) {
    if (views.length <= 1) {
      return views;
    }
    // Use CommitOrderCalculator for topological sort
    const calc = new CommitOrderCalculator();
    // Map views to numeric indices for the calculator
    const viewToIndex = new Map();
    const indexToView = new Map();
    for (let i = 0; i < views.length; i++) {
      viewToIndex.set(views[i], i);
      indexToView.set(i, views[i]);
      calc.addNode(i);
    }
    // Check each view's definition for references to other view names
    for (const view of views) {
      const definition = view.definition.toLowerCase();
      const viewIndex = viewToIndex.get(view);
      for (const otherView of views) {
        if (otherView === view) {
          continue;
        }
        // Check if the definition references the other view's name
        // Use word boundary matching to avoid false positives
        const patterns = [new RegExp(`\\b${this.escapeRegExp(otherView.name.toLowerCase())}\\b`)];
        if (otherView.schema) {
          patterns.push(
            new RegExp(`\\b${this.escapeRegExp(`${otherView.schema}.${otherView.name}`.toLowerCase())}\\b`),
          );
        }
        for (const pattern of patterns) {
          if (pattern.test(definition)) {
            // view depends on otherView, so otherView must come first
            // addDependency(from, to) puts `from` before `to` in result
            const otherIndex = viewToIndex.get(otherView);
            calc.addDependency(otherIndex, viewIndex, 1);
            break;
          }
        }
      }
    }
    // Sort and map back to views
    return calc.sort().map(index => indexToView.get(index));
  }
  escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }
}
// for back compatibility
export { SqlSchemaGenerator as SchemaGenerator };
