///|
/// An ordered JSON value.
///
/// `Json`'s object is a `Map`, whose iteration order is not something a file
/// committed to git should depend on. Snapshots are diffed by humans in code
/// review, so the writer keeps its own field order.
priv enum J {
  Str(String)
  Bool_(Bool)
  Null
  Arr(Array[J])
  Obj(Array[(String, J)])
}

///|
fn J::write(self : J, buf : StringBuilder, indent : Int) -> Unit {
  let pad = "  ".repeat(indent)
  let pad1 = "  ".repeat(indent + 1)
  match self {
    Null => buf.write_string("null")
    Bool_(b) => buf.write_string(if b { "true" } else { "false" })
    Str(s) => buf.write_string(quote(s))
    Arr(items) =>
      if items.is_empty() {
        buf.write_string("[]")
      } else {
        buf.write_string("[\n")
        for i, item in items {
          buf.write_string(pad1)
          item.write(buf, indent + 1)
          buf.write_string(if i == items.length() - 1 { "\n" } else { ",\n" })
        }
        buf.write_string(pad)
        buf.write_string("]")
      }
    Obj(entries) =>
      if entries.is_empty() {
        buf.write_string("{}")
      } else {
        buf.write_string("{\n")
        for i, kv in entries {
          let (k, v) = kv
          buf.write_string(pad1)
          buf.write_string(quote(k))
          buf.write_string(": ")
          v.write(buf, indent + 1)
          buf.write_string(if i == entries.length() - 1 { "\n" } else { ",\n" })
        }
        buf.write_string(pad)
        buf.write_string("}")
      }
  }
}

///|
/// Render a string as a JSON string literal.
fn quote(s : String) -> String {
  let buf = StringBuilder()
  buf.write_string("\"")
  for c in s {
    match c {
      '"' => buf.write_string("\\\"")
      '\\' => buf.write_string("\\\\")
      '\n' => buf.write_string("\\n")
      '\r' => buf.write_string("\\r")
      '\t' => buf.write_string("\\t")
      _ =>
        if c.to_int() < 0x20 {
          buf.write_string("\\u\{pad4(c.to_int())}")
        } else {
          buf.write_char(c)
        }
    }
  }
  buf.write_string("\"")
  buf.to_string()
}

///|
/// Four-digit lower-case hex, for `\uXXXX` escapes.
fn pad4(n : Int) -> String {
  let digits : Array[Char] = [
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
  ]
  let buf = StringBuilder()
  for shift in [12, 8, 4, 0] {
    let nibble = (n >> shift) & 0xf
    buf.write_char(digits[nibble])
  }
  buf.to_string()
}

///|
fn opt_str(v : String?) -> J {
  match v {
    Some(s) => Str(s)
    None => Null
  }
}

///|
/// Render a snapshot as the JSON that gets written to `meta/NNNN_snapshot.json`.
pub fn Snapshot::to_json_string(self : Snapshot) -> String {
  let buf = StringBuilder()
  J::Obj([
    ("version", Str(self.version)),
    ("dialect", Str(dialect_name(self.dialect))),
    ("tables", Arr(self.tables.map(table_json))),
  ]).write(buf, 0)
  buf.write_string("\n")
  buf.to_string()
}

///|
fn table_json(t : TableDef) -> J {
  Obj([
    ("name", Str(t.name)),
    ("columns", Arr(t.columns.map(column_json))),
    ("indexes", Arr(t.indexes.map(index_json))),
  ])
}

///|
fn column_json(c : ColumnDef) -> J {
  Obj([
    ("name", Str(c.name)),
    ("type", Str(c.ty)),
    ("not_null", Bool_(c.not_null)),
    ("primary_key", Bool_(c.primary_key)),
    ("autoincrement", Bool_(c.autoincrement)),
    ("unique", Bool_(c.unique)),
    ("default", opt_str(c.default_sql)),
    (
      "references",
      match c.references {
        None => Null
        Some(r) =>
          Obj([
            ("table", Str(r.table)),
            ("column", Str(r.column)),
            ("on_delete", opt_str(r.on_delete)),
            ("on_update", opt_str(r.on_update)),
          ])
      },
    ),
  ])
}

///|
fn index_json(i : IndexDef) -> J {
  Obj([
    ("name", Str(i.name)),
    ("columns", Arr(i.columns.map(c => J::Str(c)))),
    ("unique", Bool_(i.unique)),
  ])
}

///|
/// Read a snapshot back from its JSON.
///
/// Every failure is a `BadSnapshot` carrying where it went wrong: a snapshot
/// is machine-written but human-edited often enough that "invalid JSON" is not
/// a useful thing to say.
pub fn Snapshot::from_json_string(text : String) -> Snapshot raise DdlError {
  let json = @json.parse(text) catch {
    e => raise BadSnapshot(reason="not valid JSON: \{e}")
  }
  guard json is Object(root) else {
    raise BadSnapshot(reason="expected a JSON object at the top level")
  }
  let version = get_str(root, "version", "snapshot")
  guard version == SNAPSHOT_VERSION else {
    raise BadSnapshot(
      reason="snapshot format version \{version}, this aya writes \{SNAPSHOT_VERSION}",
    )
  }
  let dialect = dialect_of_name(get_str(root, "dialect", "snapshot"))
  let tables = []
  for t in get_arr(root, "tables", "snapshot") {
    tables.push(read_table(t))
  }
  { version, dialect, tables }
}

///|
fn read_table(json : Json) -> TableDef raise DdlError {
  guard json is Object(obj) else {
    raise BadSnapshot(reason="expected a table object")
  }
  let name = get_str(obj, "name", "table")
  let columns = []
  for c in get_arr(obj, "columns", "table \{name}") {
    columns.push(read_column(c, name))
  }
  let indexes = []
  for i in get_arr(obj, "indexes", "table \{name}") {
    indexes.push(read_index(i, name))
  }
  { name, columns, indexes }
}

///|
fn read_column(json : Json, table : String) -> ColumnDef raise DdlError {
  guard json is Object(obj) else {
    raise BadSnapshot(reason="expected a column object in table \{table}")
  }
  let name = get_str(obj, "name", "column in \{table}")
  let ctx = "column \{table}.\{name}"
  {
    name,
    ty: get_str(obj, "type", ctx),
    not_null: get_bool(obj, "not_null", ctx),
    primary_key: get_bool(obj, "primary_key", ctx),
    autoincrement: get_bool(obj, "autoincrement", ctx),
    unique: get_bool(obj, "unique", ctx),
    default_sql: get_opt_str(obj, "default", ctx),
    references: match obj.get("references") {
      None | Some(Null) => None
      Some(Object(r)) =>
        Some({
          table: get_str(r, "table", "\{ctx} references"),
          column: get_str(r, "column", "\{ctx} references"),
          on_delete: get_opt_str(r, "on_delete", "\{ctx} references"),
          on_update: get_opt_str(r, "on_update", "\{ctx} references"),
        })
      Some(_) =>
        raise BadSnapshot(
          reason="\{ctx}: `references` must be an object or null",
        )
    },
  }
}

///|
fn read_index(json : Json, table : String) -> IndexDef raise DdlError {
  guard json is Object(obj) else {
    raise BadSnapshot(reason="expected an index object in table \{table}")
  }
  let name = get_str(obj, "name", "index in \{table}")
  let columns = []
  for c in get_arr(obj, "columns", "index \{name}") {
    guard c is String(s) else {
      raise BadSnapshot(reason="index \{name}: column names must be strings")
    }
    columns.push(s)
  }
  { name, columns, unique: get_bool(obj, "unique", "index \{name}") }
}

///|
fn get_str(
  obj : Map[String, Json],
  key : String,
  ctx : String,
) -> String raise DdlError {
  match obj.get(key) {
    Some(String(s)) => s
    Some(_) => raise BadSnapshot(reason="\{ctx}: `\{key}` must be a string")
    None => raise BadSnapshot(reason="\{ctx}: missing `\{key}`")
  }
}

///|
fn get_opt_str(
  obj : Map[String, Json],
  key : String,
  ctx : String,
) -> String? raise DdlError {
  match obj.get(key) {
    None | Some(Null) => None
    Some(String(s)) => Some(s)
    Some(_) =>
      raise BadSnapshot(reason="\{ctx}: `\{key}` must be a string or null")
  }
}

///|
fn get_bool(
  obj : Map[String, Json],
  key : String,
  ctx : String,
) -> Bool raise DdlError {
  match obj.get(key) {
    Some(True) => true
    Some(False) => false
    Some(_) => raise BadSnapshot(reason="\{ctx}: `\{key}` must be a boolean")
    None => raise BadSnapshot(reason="\{ctx}: missing `\{key}`")
  }
}

///|
fn get_arr(
  obj : Map[String, Json],
  key : String,
  ctx : String,
) -> Array[Json] raise DdlError {
  match obj.get(key) {
    Some(Array(a)) => a
    Some(_) => raise BadSnapshot(reason="\{ctx}: `\{key}` must be an array")
    None => raise BadSnapshot(reason="\{ctx}: missing `\{key}`")
  }
}