// The unit of work: an identity map, the pending/dirty/deleted queues, and the
// flush that turns them into ordered SQL. SQLAlchemy builds this on attribute
// interception — loading a row registers the instance, touching an attribute marks
// it dirty, and `Session.flush` sorts the whole graph by mapper dependency. MoonBit
// has neither interception nor reflection, so both halves are declared instead: the
// key comes from the `Model`'s primary-key columns, and a changed record is one the
// caller hands to `update_record`. The ordering, the identity, and the single flush
// boundary are the same.
//
// The bookkeeping lives in `Core`, which `AsyncSession` drives too; only the
// statement that reaches the server differs between the two.

///|
/// Fetch by primary key, consulting the identity map before the database —
/// SQLAlchemy's `Session.get`. A row this session has already loaded comes back as
/// the *same* record instance and costs no query; anything else is a
/// `SELECT … WHERE pk = ? LIMIT 1` whose result is decoded once and kept. `None`
/// means there is no such row.
///
/// Because the instance is shared, a record with mutable fields is shared too: two
/// holders of the same row see each other's writes, which is the point of an identity
/// map. `expunge` drops one; `get_by` takes a composite key.
///
/// The map is scoped to this session *and* to the `Model` value passed in, so keep
/// the model in a `let` — one rebuilt on every call is a new descriptor each time and
/// carries no history.
pub fn[T] Session::get(
  self : Session,
  model : @schema.Model[T],
  pk : @moondb.Value,
) -> T? raise @moondb.DbError {
  self.get_by(model, [pk])
}

///|
/// `get` for a composite primary key: one value per declared key column, in
/// declaration order. Raises `QueryError` when the model declares no primary key, or
/// when `key` is not as wide as the one it declares.
pub fn[T] Session::get_by(
  self : Session,
  model : @schema.Model[T],
  key : Array[@moondb.Value],
) -> T? raise @moondb.DbError {
  let (sql, params) = key_query(model, key)
  match self.core.lookup(model, key) {
    Some(hit) => Some(hit)
    None => {
      let rows = self.driver.query(sql, params)
      self.core.adopt(model, key, rows)
    }
  }
}

///|
/// The identity-map key a `get` would look under, and the statement to run when it is
/// a miss — the two halves every read-by-key shares.
fn[T] key_query(
  model : @schema.Model[T],
  key : Array[@moondb.Value],
) -> (String, Array[@moondb.Value]) raise @moondb.DbError {
  let cols = model.pk_columns()
  if cols.length() == 0 {
    raise @moondb.QueryError(model.table + " declares no primary key")
  }
  if cols.length() != key.length() {
    raise @moondb.QueryError(
      model.table +
      " has a " +
      cols.length().to_string() +
      "-column primary key, given " +
      key.length().to_string(),
    )
  }
  let mut stmt = model.select()
  for i in 0.. Unit raise @moondb.DbError {
  defer self.core.sweep()
  self.core.fire(HookContext::new(BeforeFlush))
  for w in plan(self.core.pending) {
    self.run(w, BeforeInsert, AfterInsert)
  }
  for w in self.core.dirty {
    self.run(w, BeforeUpdate, AfterUpdate)
  }
  let order = plan(self.core.deleted)
  for i = order.length() - 1; i >= 0; i = i - 1 {
    self.run(order[i], BeforeDelete, AfterDelete)
  }
  self.core.fire(HookContext::new(AfterFlush))
}

///|
/// Render one queued write, fire its hooks around it, run its statements, and settle
/// its identity bookkeeping.
///
/// The row's columns are read first, so a `Before*` listener sees what is about to be
/// written and can rewrite it. The `After*` listener is handed a statement that ran
/// — the last one, when a joined mapping wrote two — so it can log what reached the
/// database.
fn Session::run(
  self : Session,
  w : Work,
  before : HookEvent,
  after : HookEvent,
) -> Unit raise @moondb.DbError {
  let ctx = HookContext::new(before, table=w.table, columns=(w.columns)())
  self.core.fire(ctx)
  let statements = (w.emit)(ctx.columns)
  let mut last_sql = ""
  let mut last_params : Array[@moondb.Value] = []
  for statement in statements {
    let (sql, params) = statement
    self.driver.execute(sql, params) |> ignore
    last_sql = sql
    last_params = params
  }
  let done = HookContext::new(after, table=w.table, columns=ctx.columns)
  done.sql = last_sql
  done.params = last_params
  self.core.fire(done)
  (w.settle)()
  w.done = true
}

///|
/// How many records are queued for insertion — SQLAlchemy's `Session.new`.
pub fn Session::pending_count(self : Session) -> Int32 {
  self.core.pending.length()
}

///|
/// How many records are queued for update — SQLAlchemy's `Session.dirty`.
pub fn Session::dirty_count(self : Session) -> Int32 {
  self.core.dirty.length()
}

///|
/// How many records are queued for deletion — SQLAlchemy's `Session.deleted`.
pub fn Session::deleted_count(self : Session) -> Int32 {
  self.core.deleted.length()
}

///|
/// Drop `record` from the identity map, so the next `get` reads the row back from the
/// database and returns a fresh instance. SQLAlchemy's `Session.expunge`. Queued work
/// for the record is left alone.
pub fn[T] Session::expunge(
  self : Session,
  model : @schema.Model[T],
  record : T,
) -> Unit {
  self.core.forget(model, record)
}

///|
/// Empty the identity map: every model this session has loaded through forgets it.
/// SQLAlchemy's `Session.expunge_all`.
pub fn Session::expunge_all(self : Session) -> Unit {
  self.core.expunge_all()
}

///|
/// Mark every loaded record stale, SQLAlchemy's `Session.expire_all`: the next `get`
/// reads the row back instead of handing out the copy this session kept. MoonBit
/// records are read through a call, not an attribute, so expiring eagerly here and
/// reloading lazily there come to the same thing — both drop the copy.
pub fn Session::expire_all(self : Session) -> Unit {
  self.core.expunge_all()
}