///|
/// Database operations used by the migrator.
///
/// Ensure storage exists before listing or recording migrations. The migrator
/// calls ensure_migration_table at the start of each valid-target run.
///
/// Only applied migrations are stored, in execution order. Recording true
/// appends an ID, and false deletes it after a successful downgrade. Operations
/// receive the same table name, which adapters treat as a single identifier.
///
/// Callers must serialize migration runs. To make callbacks and their records
/// atomic, run the migrator on the same connection inside a caller-owned
/// transaction. This trait does not start transactions or acquire locks.
pub(open) trait Migrate {
  async fn ensure_migration_table(Self, table~ : String) -> Unit
  async fn list_migrations(Self, table~ : String) -> Array[String]
  async fn record_migration(Self, String, Bool, table~ : String) -> Unit
}

///|
/// A migration's two callbacks. Normal completion indicates success.
/// Raised errors stop the run and propagate without marking success.
/// The ID and execution order are supplied by the constructor's Map.
pub(all) struct Migration[T] {
  upgrade : async (T) -> Unit
  downgrade : async (T) -> Unit
}

///|
/// A snapshot of a migration map's insertion order and callbacks.
/// Database handles are supplied separately for each migration run.
struct Migrator[T] {
  migrations : Array[(String, Migration[T])]
  table : String
}

///|
/// Construct a migrator. Later mutations of the input map do not change it.
/// IDs are opaque strings, including the empty string.
/// table selects the migrations table, not the tables changed by callbacks.
pub fn[T] Migrator::Migrator(
  migrations : Map[String, Migration[T]],
  table~ : String = "_moonbit_migrations",
) -> Self[T] {
  { migrations: migrations.iter().to_array(), table, }
}

///|
/// Validate applied history and apply all pending
/// migrations in map insertion order. Already applied migrations are skipped.
pub async fn[T : Migrate] Migrator::run(self : Self[T], db : T) -> Unit {
  let applied = self.prepare(db)
  for index, (version, migration) in self.migrations {
    if index >= applied {
      (migration.upgrade)(db)
      db.record_migration(version, true, table=self.table)
    }
  }
}

///|
/// Revert applied migrations after the target ID, in reverse insertion order.
/// The target remains applied. Omit version or pass None to revert everything.
/// An unknown or currently unapplied target is rejected before callbacks or
/// changes to migration records. The adapter may initialize an empty table.
pub async fn[T : Migrate] Migrator::revert(
  self : Self[T],
  db : T,
  version? : String,
) -> Unit {
  let target = if version is Some(version) {
    let target = self.migrations.search_by(m => m.0 == version)
    guard target is Some(index) else {
      raise MigrationError::UnknownVersion(version)
    }
    index + 1
  } else {
    0
  }
  let applied = self.prepare(db)
  if version is Some(version) && target > applied {
    raise MigrationError::TargetNotApplied(version)
  }
  for index in @range.iter(from=applied - 1, to=target, step=-1, inclusive=true) {
    let (id, migration) = self.migrations[index]
    (migration.downgrade)(db)
    db.record_migration(id, false, table=self.table)
  }
}

///|
fn[T] Migrator::validate(
  self : Self[T],
  records : Array[String],
) -> Int32 raise MigrationError {
  for index, version in records {
    guard self.migrations.get(index) is Some((expected, _)) else {
      raise MigrationError::MissingMigration(version)
    }
    if version != expected {
      raise MigrationError::HistoryMismatch(version, expected)
    }
  }
  records.length()
}

///|
async fn[T : Migrate] Migrator::prepare(self : Self[T], db : T) -> Int32 {
  db.ensure_migration_table(table=self.table)
  self.validate(db.list_migrations(table=self.table))
}