///|
/// Ordered list of `Field`s describing a `DataFrame`'s columns.
///
/// Construction always validates uniqueness of names; once built, a
/// `Schema` is guaranteed to have no duplicate column names.
///
/// The `fields` array is `priv`: build a schema with `Schema::Schema` and read a
/// copy via `fields()` / `field_names()`. External code cannot reach or
/// mutate the backing array, so a validated schema stays valid.
///
/// `index` is the name→position map behind `index_of`, and through it `field` /
/// `select` / `rename`. It is derived from `fields` and built in lock-step with
/// it, never separately: the constructors are the only writers, and each one
/// builds both. Uniqueness is what makes it total — every name maps to exactly
/// the position that carries it — so it is the same validation pass that
/// establishes both. A schema is immutable, so one scan at construction replaces
/// a scan per lookup: resolving `c` names against a `c`-column schema was `O(c²)`
/// while every resolution walked the array, which is the shape a wide `select`
/// or `rename` hits.
pub struct Schema {
  priv fields : Array[Field]
  priv index : Map[String, Int]
} derive(Eq, Debug)

///|
pub extend Schema with Eq::{equal, not_equal}

///|
pub extend Schema with Show::{to_string, output}

///|
pub extend Schema with Debug::{to_repr}

///|
/// Build a schema from a list of fields. `raise DuplicateColumn(name)` on
/// the first repeated name.
///
/// The input array is copied, so mutating `fields` after construction
/// cannot alter the schema or break the validated no-duplicates invariant.
///
/// The type's own constructor, like `Field::Field` — the spelling every
/// canonically-constructed type in MoonFrame uses.
pub fn Schema::Schema(fields : Array[Field]) -> Schema raise DataError {
  { fields: fields.copy(), index: name_index(fields) }
}

///|
/// The name→position map for a field vector, `raise DuplicateColumn(name)` on
/// the first repeated name. The uniqueness check and the lookup map are the same
/// pass on purpose: a map keyed by name *is* the check (a name already present is
/// the duplicate), and building them separately is how the two could disagree.
/// Every `Schema` built from a field vector comes through here; `select`, which
/// walks names rather than fields, runs the same one-pass rule inline.
fn name_index(fields : Array[Field]) -> Map[String, Int] raise DataError {
  let index : Map[String, Int] = Map([])
  for i, f in fields {
    if index.contains(f.name) {
      raise DuplicateColumn(f.name)
    }
    index[f.name] = i
  }
  index
}

///|
/// Return a snapshot of the schema's fields.
///
/// A fresh array is returned so callers cannot mutate the schema in place
/// and break the "no duplicate names" invariant enforced by `Schema::Schema`.
pub fn Schema::fields(self : Schema) -> Array[Field] {
  self.fields.copy()
}

///|
/// Number of columns in the schema.
pub fn Schema::len(self : Schema) -> Int {
  self.fields.length()
}

///|
/// `true` when the schema has no columns.
pub fn Schema::is_empty(self : Schema) -> Bool {
  self.fields.length() == 0
}

///|
/// Column names in declaration order.
pub fn Schema::field_names(self : Schema) -> Array[String] {
  self.fields.map(fn(f) { f.name })
}

///|
/// The index of the column named `name`, or `raise ColumnNotFound(name)`.
/// `O(1)` — the name index is built once, when the schema is.
pub fn Schema::index_of(self : Schema, name : String) -> Int raise DataError {
  match self.index.get(name) {
    Some(i) => i
    None => raise ColumnNotFound(name)
  }
}

///|
/// Return the `Field` for `name`, or `raise ColumnNotFound(name)`.
pub fn Schema::field(self : Schema, name : String) -> Field raise DataError {
  self.fields[self.index_of(name)]
}

///|
/// Return the `Field` at position `i`, or `raise IndexOutOfBounds(i)`.
pub fn Schema::field_at(self : Schema, i : Int) -> Field raise DataError {
  if i < 0 || i >= self.fields.length() {
    raise IndexOutOfBounds(i)
  }
  self.fields[i]
}

///|
/// Project a sub-schema by name, preserving the order of `names`. Missing
/// names `raise ColumnNotFound(name)`; duplicates inside `names`
/// `raise DuplicateColumn(name)`.
pub fn Schema::select(
  self : Schema,
  names : Array[String],
) -> Schema raise DataError {
  let picked : Array[Field] = []
  // The result's own name index doubles as the duplicate check, so the
  // projection needs no second membership structure — and the checks stay in
  // `names` order, which is what decides *which* error a bad selection reports.
  let index : Map[String, Int] = Map([])
  for n in names {
    if index.contains(n) {
      raise DuplicateColumn(n)
    }
    // `field` raises `ColumnNotFound(n)` for an unknown name; it propagates.
    picked.push(self.field(n))
    index[n] = picked.length() - 1
  }
  { fields: picked, index }
}

///|
/// Rename `old_name` to `new_name`. Raises if `old_name` is missing or
/// `new_name` collides with another existing column.
pub fn Schema::rename(
  self : Schema,
  old_name : String,
  new_name : String,
) -> Schema raise DataError {
  if old_name == new_name {
    // No-op rename: still validate that old_name exists (raises if not).
    let _ = self.index_of(old_name)
    return self
  }
  let idx = self.index_of(old_name)
  // A *present* `new_name` is the collision case — an `O(1)` membership check
  // against the name index, with no raising probe.
  if self.index.contains(new_name) {
    raise DuplicateColumn(new_name)
  }
  let next = self.fields.copy()
  next[idx] = self.fields[idx].rename(new_name)
  // Rebuilt rather than patched: the names are unique by the check above, so
  // this cannot raise, and one derivation of the index means there is no second
  // place for it to fall out of step with the fields.
  { fields: next, index: name_index(next) }
}

///|
/// `Show` renders `Schema([Field { ... }, ...])` for assertion snapshots.
pub impl Show for Schema with fn output(self, logger) {
  logger.write_string("Schema([")
  logger.write_string(self.fields.map(f => f.to_string()).join(", "))
  logger.write_string("])")
}