///|
pub(all) struct Difference {
  kind : String
  table : String
  row_key : String
  field : String
  before : Value?
  after : Value?
}

///|
pub(all) struct Comparison {
  differences : Array[Difference]
  truncated : Bool
}

///|
pub fn Comparison::equal(self : Comparison) -> Bool {
  self.differences.is_empty() && !self.truncated
}

///|
fn primary_field(entity : Entity) -> String? {
  for field in entity.fields {
    if field.primary {
      return Some(field.name)
    }
  }
  None
}

///|
fn keyed_rows(table : Table, key : String?) -> Result[Map[String, Row], Issue] {
  let rows : Map[String, Row] = Map([])
  for index in 0.. "index:" + index.to_string()
      Some(name) =>
        match row.get(name) {
          Some(Null) | None =>
            return Err(
              issue(
                "diff_missing_key",
                table.name,
                "Row key is missing or null",
              ),
            )
          Some(value) => value.key()
        }
    }
    if rows.contains(identity) {
      return Err(
        issue("diff_duplicate_key", table.name, "Row identity is not unique"),
      )
    }
    let names : Map[String, Bool] = Map([])
    for cell in row.cells {
      if names.contains(cell.name) {
        return Err(
          issue(
            "diff_duplicate_cell",
            table.name,
            "Row contains duplicate field names",
          ),
        )
      }
      names[cell.name] = true
    }
    rows[identity] = row
  }
  Ok(rows)
}

///|
fn unique_tables(data : Dataset) -> Result[Map[String, Table], Issue] {
  let tables : Map[String, Table] = Map([])
  for table in data.tables {
    if tables.contains(table.name) {
      return Err(
        issue("diff_duplicate_table", table.name, "Table names must be unique"),
      )
    }
    tables[table.name] = table
  }
  Ok(tables)
}

///|
/// Compare fixture content by declared primary keys, otherwise by row position.
/// Metadata is intentionally separate from content, allowing cross-seed analysis.
pub fn compare_datasets(
  model : Model,
  before : Dataset,
  after : Dataset,
  max_differences? : Int = 100,
) -> Result[Comparison, Issue] {
  if compile(model) is Err(errors) {
    return Err(errors[0])
  }
  if max_differences <= 0 {
    return Err(
      issue("diff_limit", "comparison", "Difference limit must be positive"),
    )
  }
  let old_tables = match unique_tables(before) {
    Ok(tables) => tables
    Err(error) => return Err(error)
  }
  let new_tables = match unique_tables(after) {
    Ok(tables) => tables
    Err(error) => return Err(error)
  }
  let names : Array[String] = []
  for table in before.tables {
    names.push(table.name)
  }
  for table in after.tables {
    if !names.contains(table.name) {
      names.push(table.name)
    }
  }
  names.sort()
  let differences : Array[Difference] = []
  let mut truncated = false
  fn add(
    kind : String,
    table : String,
    row_key : String,
    field : String,
    before : Value?,
    after : Value?,
  ) -> Unit {
    if differences.length() >= max_differences {
      truncated = true
    } else {
      differences.push({ kind, table, row_key, field, before, after, })
    }
  }
  for name in names {
    let key = match model.entity(name) {
      Some(entity) => primary_field(entity)
      None => None
    }
    let (old_table, new_table) = match
      (old_tables.get(name), new_tables.get(name)) {
      (None, Some(_)) => {
        add("table_added", name, "", "", None, None)
        continue
      }
      (Some(_), None) => {
        add("table_removed", name, "", "", None, None)
        continue
      }
      (Some(a), Some(b)) => (a, b)
      _ => continue
    }
    let old_rows = match keyed_rows(old_table, key) {
      Ok(rows) => rows
      Err(error) => return Err(error)
    }
    let new_rows = match keyed_rows(new_table, key) {
      Ok(rows) => rows
      Err(error) => return Err(error)
    }
    let identities : Array[String] = []
    for id, _ in old_rows {
      identities.push(id)
    }
    for id, _ in new_rows {
      if !old_rows.contains(id) {
        identities.push(id)
      }
    }
    identities.sort()
    for id in identities {
      let (old_row, new_row) = match (old_rows.get(id), new_rows.get(id)) {
        (None, Some(_)) => {
          add("row_added", name, id, "", None, None)
          continue
        }
        (Some(_), None) => {
          add("row_removed", name, id, "", None, None)
          continue
        }
        (Some(a), Some(b)) => (a, b)
        _ => continue
      }
      let fields : Array[String] = old_row.cells.map(cell => cell.name)
      for cell in new_row.cells {
        if !fields.contains(cell.name) {
          fields.push(cell.name)
        }
      }
      fields.sort()
      for field in fields {
        let old = old_row.get(field)
        let new = new_row.get(field)
        if old != new {
          add("cell_changed", name, id, field, old, new)
        }
      }
    }
  }
  Ok({ differences, truncated, })
}

///|
pub fn Difference::to_json(self : Difference) -> Json {
  Json::object(
    Map([
      ("kind", Json::string(self.kind)),
      ("table", Json::string(self.table)),
      ("row_key", Json::string(self.row_key)),
      ("field", Json::string(self.field)),
      ("before_present", Json::boolean(self.before is Some(_))),
      ("after_present", Json::boolean(self.after is Some(_))),
      (
        "before",
        match self.before {
          Some(value) => value.to_json()
          None => Json::null()
        },
      ),
      (
        "after",
        match self.after {
          Some(value) => value.to_json()
          None => Json::null()
        },
      ),
    ]),
  )
}

///|
pub fn Comparison::to_json(self : Comparison) -> Json {
  Json::object(
    Map([
      ("equal", Json::boolean(self.equal())),
      ("truncated", Json::boolean(self.truncated)),
      ("differences", Json::array(self.differences.map(diff => diff.to_json()))),
    ]),
  )
}