///|
fn inferred_type(value : Json) -> Int32 raise FgbError {
  match value {
    Null => -1
    True | False => 2
    String(_) => 11
    Array(_) | Object(_) => 12
    Number(n, ..) => {
      if !finite(n) {
        raise InvalidArgument("non-finite property")
      }
      let signed : Int64? = Some(
        @json.from_json(Json::string(value.stringify())),
      ) catch {
        _ => None
      }
      if signed is Some(_) {
        return 7
      }
      let unsigned : UInt64? = Some(
        @json.from_json(Json::string(value.stringify())),
      ) catch {
        _ => None
      }
      if unsigned is Some(_) {
        return 8
      }
      if n >= -9007199254740991.0 &&
        n <= 9007199254740991.0 &&
        n == n.to_int64().to_double() {
        return 7
      }
      if n == @math.floor(n) &&
        (n < -9007199254740991.0 || n > 9007199254740991.0) {
        raise InvalidArgument(
          "large integer cannot be inferred without a precise 64-bit representation",
        )
      }
      10
    }
  }
}

///|
fn unify_types(left : Int32, right : Int32) -> Int32 raise FgbError {
  if left == -1 {
    return right
  }
  if right == -1 || left == right {
    return left
  }
  if (left == 7 && right == 10) || (left == 10 && right == 7) {
    return 10
  }
  // Signed/unsigned mixtures require an explicit schema to avoid precision loss.
  raise InvalidArgument(
    "inconsistent GeoJSON property types; provide an explicit schema",
  )
}

///|
pub fn infer_geojson_header(
  value : Json,
  name~ : String = "",
  limits~ : Limits = Limits::default(),
) -> Header raise FgbError {
  limits.validate()
  if json_text(json_field(value, "type")) != "FeatureCollection" {
    raise InvalidArgument("expected FeatureCollection")
  }
  let features = json_array(json_field(value, "features"))
  enforce(features.length(), limits.max_features, "GeoJSON features")
  let types : Map[String, Int32] = Map([])
  let present : Map[String, Int32] = Map([])
  let nullable : Map[String, Bool] = Map([])
  let mut geometry_kind = Unknown
  let mut geometry_seen = false
  let mut mixed = false
  for feature in features {
    if json_text(json_field(feature, "type")) != "Feature" {
      raise InvalidArgument("expected Feature in collection")
    }
    let geometry = json_field(feature, "geometry")
    if geometry != Json::null() {
      let kind = match json_text(json_field(geometry, "type")) {
        "Point" => Point
        "LineString" => LineString
        "Polygon" => Polygon
        "MultiPoint" => MultiPoint
        "MultiLineString" => MultiLineString
        "MultiPolygon" => MultiPolygon
        other => raise Unsupported("GeoJSON geometry \{other}")
      }
      if geometry_seen && geometry_kind != kind {
        mixed = true
      }
      geometry_kind = kind
      geometry_seen = true
    }
    let fields = match json_field(feature, "properties") {
      Object(fields) => fields
      Null => Map([])
      _ => raise InvalidArgument("properties must be an object or null")
    }
    for key, value in fields {
      if key.is_empty() {
        raise InvalidArgument("empty property name")
      }
      let old = types.get(key).unwrap_or(-1)
      types[key] = unify_types(old, inferred_type(value))
      present[key] = present.get(key).unwrap_or(0) + 1
      if value == Json::null() {
        nullable[key] = true
      }
    }
    enforce(types.length(), limits.max_columns, "inferred columns")
  }
  // Inferred Double must not silently round a large integer from another row.
  for feature in features {
    match json_field(feature, "properties") {
      Object(fields) =>
        for key, value in fields {
          if types.get(key) == Some(10) {
            match value {
              Number(n, ..) if n == @math.floor(n) &&
                (n < -9007199254740991.0 || n > 9007199254740991.0) =>
                raise InvalidArgument(
                  "mixed decimal and large integer values need an explicit schema",
                )
              _ => ()
            }
          }
        }
      _ => ()
    }
  }
  let names : Array[String] = []
  for key, _ in types {
    names.push(key)
  }
  names.sort()
  let columns : Array[Column] = []
  for key in names {
    let type_code = types.get(key).unwrap_or(-1)
    columns.push(
      Column::new(
        key,
        if type_code < 0 {
          11
        } else {
          type_code
        },
        nullable=nullable.get(key).unwrap_or(false) ||
          present.get(key).unwrap_or(0) != features.length(),
      ),
    )
  }
  {
    ..Header::new(if mixed { Unknown } else { geometry_kind }, columns),
    name,
    crs: Some(Crs::epsg(4326)),
  }
}

///|
pub fn import_geojson(
  value : Json,
  name~ : String = "",
  limits~ : Limits = Limits::default(),
) -> Dataset raise FgbError {
  let header = infer_geojson_header(value, name~, limits~)
  dataset_from_geojson(value, header, limits~)
}

///|
pub(all) struct SchemaDifference {
  column : String
  message : String
} derive(Eq, Debug, ToJson, @json.FromJson)

///|
pub fn compare_schemas(
  left : Header,
  right : Header,
) -> Array[SchemaDifference] {
  let result : Array[SchemaDifference] = []
  if left.geometry_kind != right.geometry_kind {
    result.push({ column: "geometry", message: "geometry types differ", })
  }
  let left_columns : Map[String, Column] = Map([])
  let right_columns : Map[String, Column] = Map([])
  for column in left.columns {
    left_columns[column.name] = column
  }
  for column in right.columns {
    right_columns[column.name] = column
  }
  for column in left.columns {
    match right_columns.get(column.name) {
      None =>
        result.push({ column: column.name, message: "missing in right schema", })
      Some(other) => {
        if column.type_code != other.type_code {
          result.push({ column: column.name, message: "column types differ", })
        }
        if column.nullable && !other.nullable {
          result.push({
            column: column.name,
            message: "right schema forbids existing null values",
          })
        }
      }
    }
  }
  for column in right.columns {
    if !left_columns.contains(column.name) {
      result.push({ column: column.name, message: "new in right schema", })
    }
  }
  result
}