// Autogenerate: compare a declared schema with a reflected one and say what would have
// to change. Between the two is an ordered list of operations, and — because every
// operation here has an exact inverse over data we already hold — the list that undoes
// it as well. That is the difference from Alembic, which leaves `downgrade()` empty and
// asks you to write it.

///|
/// One change and the change that undoes it, both derived from the same comparison —
/// so the downgrade of an autogenerated revision is written for you and cannot drift
/// from the upgrade.
pub(all) struct Change {
  up : Op
  down : Op
}

///|
/// The difference between a declared schema and a reflected one, as up/down pairs.
///
/// The order is the order a migration must run in: create missing tables and their
/// indexes first, then bring existing tables in line column by column, then drop the
/// indexes and tables that are gone. Columns are compared by name; a type compares as
/// `dialect` spells it, so an `Int64` reflected as `INTEGER` on SQLite is not a change;
/// nullability is compared only off the primary key, which SQLite reports as nullable
/// because `INTEGER PRIMARY KEY` is the rowid.
pub fn autogenerate_changes(
  declared : Array[@schema.TableSpec],
  reflected : Array[@schema.TableSpec],
  dialect? : @query.Dialect,
) -> Array[Change] {
  let out : Array[Change] = []
  // Creates: tables the model declares that the database has never seen.
  for table in declared {
    if !has_table(reflected, table.name) {
      out.push({
        up: create_table(table.name, table.columns),
        down: drop_table(table.name, if_exists=true),
      })
      for ix in table.indexes {
        out.push(index_change(table, ix))
      }
    }
  }
  // Alters: tables on both sides.
  for table in declared {
    match find_table(reflected, table.name) {
      None => ()
      Some(live) => {
        for column in table.columns {
          match find_column(live.columns, column.name) {
            None =>
              out.push({
                up: add_column(table.name, column),
                down: drop_column(table.name, column.name),
              })
            Some(was) =>
              for change in column_changes(table.name, was, column, dialect) {
                out.push(change)
              }
          }
        }
        for column in live.columns {
          if find_column(table.columns, column.name) is None {
            out.push({
              up: drop_column(table.name, column.name),
              down: add_column(table.name, column),
            })
          }
        }
        for change in index_changes(table, live, dialect) {
          out.push(change)
        }
      }
    }
  }
  // Drops: tables the model no longer declares.
  for table in reflected {
    if !has_table(declared, table.name) {
      out.push({
        up: drop_table(table.name, if_exists=true),
        down: create_table(table.name, table.columns),
      })
      for ix in table.indexes {
        out.push({
          up: drop_index(
            index_name_of(table.name, ix),
            table=table.name,
            if_exists=true,
          ),
          down: create_index(
            index_name_of(table.name, ix),
            table.name,
            ix.columns,
            unique=ix.unique,
          ),
        })
      }
    }
  }
  out
}

///|
/// Just the operations an upgrade needs, in order — what a migration body contains.
pub fn autogenerate(
  declared : Array[@schema.TableSpec],
  reflected : Array[@schema.TableSpec],
  dialect? : @query.Dialect,
) -> Array[Op] {
  autogenerate_changes(declared, reflected, dialect?).map(c => c.up)
}

///|
/// A revision built from the difference, with its `up` and `down` both filled in. The
/// downgrade is exact over the reflected state, which is what makes it safe to run.
pub fn autogenerate_revision(
  id~ : String,
  parents~ : Array[String] = [],
  name~ : String,
  declared : Array[@schema.TableSpec],
  reflected : Array[@schema.TableSpec],
  dialect? : @query.Dialect,
) -> Revision {
  let changes = autogenerate_changes(declared, reflected, dialect?)
  let up : Array[Op] = []
  for change in changes {
    up.push(change.up)
  }
  // Undo in the reverse order: the last thing created is the first thing dropped.
  let down : Array[Op] = []
  for i = changes.length() - 1; i >= 0; i = i - 1 {
    down.push(changes[i].down)
  }
  Revision::new(id~, parents~, name~, up~, down~)
}

///|
fn has_table(tables : Array[@schema.TableSpec], name : String) -> Bool {
  tables.iter().find_first(t => t.name == name) is Some(_)
}

///|
fn find_table(
  tables : Array[@schema.TableSpec],
  name : String,
) -> @schema.TableSpec? {
  tables.iter().find_first(t => t.name == name)
}

///|
fn find_column(
  columns : Array[@schema.Column],
  name : String,
) -> @schema.Column? {
  columns.iter().find_first(c => c.name == name)
}

///|
/// The name an index is known by, applying the `ix_`/`uq_` default the DDL uses.
fn index_name_of(table : String, ix : @schema.Index) -> String {
  match ix.name {
    Some(n) => n
    None =>
      (if ix.unique { "uq_" } else { "ix_" }) +
      table +
      "_" +
      ix.columns.join("_")
  }
}

///|
/// The changes one column needs: its type, and its nullability when it is not a key.
/// Both are `AlterColumn` operations carrying only the property that moved, so the
/// statement stays a single `ALTER` rather than a full redefinition.
fn column_changes(
  table : String,
  was : @schema.Column,
  want : @schema.Column,
  dialect : @query.Dialect?,
) -> Array[Change] {
  let out : Array[Change] = []
  let same_type = match dialect {
    Some(d) => was.col_type.sql_for(d) == want.col_type.sql_for(d)
    None => was.col_type == want.col_type
  }
  if !same_type {
    out.push({
      up: alter_column(table, want.name, col_type=want.col_type),
      down: alter_column(table, want.name, col_type=was.col_type),
    })
  }
  if !want.primary_key && !was.primary_key && was.nullable != want.nullable {
    out.push({
      up: alter_column(table, want.name, nullable=want.nullable),
      down: alter_column(table, want.name, nullable=was.nullable),
    })
  }
  out
}

///|
/// The changes an index needs, comparing by name and then by what it covers.
fn index_changes(
  declared : @schema.TableSpec,
  reflected : @schema.TableSpec,
  dialect : @query.Dialect?,
) -> Array[Change] {
  let out : Array[Change] = []
  for ix in declared.indexes {
    let name = index_name_of(declared.name, ix)
    match
      reflected.indexes
      .iter()
      .find_first(other => index_name_of(reflected.name, other) == name) {
      None =>
        out.push({
          up: create_index(
            name,
            declared.name,
            ix.columns,
            unique=ix.unique,
            if_not_exists=true,
          ),
          down: drop_index(name, table=declared.name, if_exists=true),
        })
      Some(live) =>
        if live.columns != ix.columns || live.unique != ix.unique {
          // An index is replaced, not altered: drop the old, create the new, and the
          // inverse is the same pair the other way round.
          out.push({
            up: drop_index(name, table=declared.name, if_exists=true),
            down: create_index(
              name,
              declared.name,
              live.columns,
              unique=live.unique,
            ),
          })
          out.push({
            up: create_index(name, declared.name, ix.columns, unique=ix.unique),
            down: drop_index(name, table=declared.name, if_exists=true),
          })
        }
    }
  }
  for ix in reflected.indexes {
    let name = index_name_of(reflected.name, ix)
    if declared.indexes
      .iter()
      .find_first(other => index_name_of(declared.name, other) == name)
      is None {
      out.push({
        up: drop_index(name, table=reflected.name, if_exists=true),
        down: create_index(name, reflected.name, ix.columns, unique=ix.unique),
      })
    }
  }
  ignore(dialect)
  out
}

///|
/// One index introduced with a table: created with it, dropped before it.
fn index_change(table : @schema.TableSpec, ix : @schema.Index) -> Change {
  let name = index_name_of(table.name, ix)
  {
    up: create_index(name, table.name, ix.columns, unique=ix.unique),
    down: drop_index(name, table=table.name, if_exists=true),
  }
}