src/geokit/geojson.gleam

//// GeoJSON (RFC 7946) encode / decode.
////
//// Maps geokit's [`Geometry`](../geometry.html) ADT to and from the
//// JSON shapes defined in RFC 7946:
////
//// - `Point` / `LineString` / `Polygon` / `MultiPolygon` round-trip
////   through this module.
//// - `MultiPoint`, `MultiLineString`, and `GeometryCollection` are
////   valid GeoJSON types but are not currently representable in
////   `Geometry`; decoding returns [`UnsupportedType`](#GeoJsonError).
////
//// Coordinate order in GeoJSON is `[longitude, latitude]`, opposite
//// of the `lat: ..., lng: ...` constructors elsewhere in geokit.
//// The encoder and decoder handle the swap so callers never see the
//// reversed order. Altitude (a third coordinate) is accepted on
//// decode but discarded.
////
//// Properties on [`Feature`](#Feature) and `FeatureCollection` are
//// user-typed: pass a `Json` builder when encoding and a
//// `decode.Decoder` when decoding. Use `gleam/dynamic/decode.dynamic`
//// and `gleam/json.null()` if you don't care about properties.

import gleam/dynamic/decode.{type Decoder}
import gleam/json.{type Json}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result

import geokit/geometry.{type Geometry}
import geokit/latlng.{type LatLng}

// --- Public types --------------------------------------------------------

/// Errors returned by the decoders.
pub type GeoJsonError {
  /// The input was not valid JSON.
  InvalidJson(reason: String)
  /// The input was valid JSON but the structure did not match the
  /// expected GeoJSON shape (missing field, wrong field type, ...).
  InvalidStructure(reason: String)
  /// The `type` field carried a string that is not a known GeoJSON
  /// geometry or container type.
  UnknownType(type_: String)
  /// The `type` is a valid GeoJSON type but cannot be represented in
  /// geokit's `Geometry` ADT (`MultiPoint`, `MultiLineString`,
  /// `GeometryCollection`).
  UnsupportedType(type_: String)
  /// A coordinate array did not have the expected shape — fewer than
  /// two elements, or non-numeric entries.
  InvalidPosition(coords: List(Float))
  /// A coordinate pair was structurally valid but the values fell
  /// outside the documented domain of `latlng.new`.
  InvalidLatLng(error: latlng.LatLngError)
}

/// A GeoJSON `id` value. RFC 7946 §3.2 allows either a JSON string
/// or a JSON number; this type captures both losslessly.
pub type FeatureId {
  StringId(value: String)
  IntId(value: Int)
}

/// A GeoJSON Feature. Properties are user-typed: the type parameter
/// `properties` is whatever your application chooses (often a record
/// type, sometimes a `dict.Dict(String, dynamic.Dynamic)` for fully
/// dynamic payloads).
pub type Feature(properties) {
  Feature(geometry: Geometry, properties: properties, id: Option(FeatureId))
}

// --- Encoding ------------------------------------------------------------

/// Encode a `Geometry` as a GeoJSON string. The result is a compact
/// JSON document with no whitespace.
///
/// ```gleam
/// import geokit/geojson
/// import geokit/geometry
/// import geokit/latlng
///
/// let assert Ok(p) = latlng.new(lat: 35.0, lng: 139.0)
/// geojson.encode_geometry(geometry: geometry.Point(p))
/// // == "{\"type\":\"Point\",\"coordinates\":[139.0,35.0]}"
/// ```
pub fn encode_geometry(geometry geometry: Geometry) -> String {
  geometry |> geometry_to_json |> json.to_string
}

/// Encode a `Feature` as a GeoJSON string. Pass a function that turns
/// your properties type into a `Json` value.
pub fn encode_feature(
  feature feature: Feature(p),
  properties to_json: fn(p) -> Json,
) -> String {
  feature |> feature_to_json(to_json) |> json.to_string
}

/// Encode a list of features as a GeoJSON `FeatureCollection`.
pub fn encode_feature_collection(
  features features: List(Feature(p)),
  properties to_json: fn(p) -> Json,
) -> String {
  let entries = [
    #("type", json.string("FeatureCollection")),
    #(
      "features",
      json.preprocessed_array(
        list.map(features, fn(f) { feature_to_json(f, to_json) }),
      ),
    ),
  ]
  json.object(entries) |> json.to_string
}

// --- Decoding ------------------------------------------------------------

/// Decode a GeoJSON geometry string back to a `Geometry`.
pub fn decode_geometry(input input: String) -> Result(Geometry, GeoJsonError) {
  parse_with(input, geometry_decoder())
}

/// Decode a GeoJSON Feature. Pass a decoder for your properties type.
/// To accept any shape, pass `decode.dynamic`.
pub fn decode_feature(
  input input: String,
  properties properties: Decoder(p),
) -> Result(Feature(p), GeoJsonError) {
  parse_with(input, feature_decoder(properties))
}

/// Decode a GeoJSON FeatureCollection into a list of features.
pub fn decode_feature_collection(
  input input: String,
  properties properties: Decoder(p),
) -> Result(List(Feature(p)), GeoJsonError) {
  parse_with(input, feature_collection_decoder(properties))
}

// --- Geometry → JSON -----------------------------------------------------

fn geometry_to_json(g: Geometry) -> Json {
  case g {
    geometry.Point(p) -> object_with_coords("Point", position_to_json(p))
    geometry.LineString(points) ->
      object_with_coords(
        "LineString",
        json.preprocessed_array(list.map(points, position_to_json)),
      )
    geometry.Polygon(rings) ->
      object_with_coords("Polygon", rings_to_json(rings))
    geometry.MultiPolygon(polygons) ->
      object_with_coords(
        "MultiPolygon",
        json.preprocessed_array(list.map(polygons, rings_to_json)),
      )
  }
}

fn rings_to_json(rings: List(List(LatLng))) -> Json {
  json.preprocessed_array(
    list.map(rings, fn(ring) {
      json.preprocessed_array(list.map(ring, position_to_json))
    }),
  )
}

fn position_to_json(p: LatLng) -> Json {
  // GeoJSON order: [longitude, latitude].
  json.preprocessed_array([
    json.float(latlng.lng(p)),
    json.float(latlng.lat(p)),
  ])
}

fn object_with_coords(type_name: String, coordinates: Json) -> Json {
  json.object([
    #("type", json.string(type_name)),
    #("coordinates", coordinates),
  ])
}

// --- Feature → JSON ------------------------------------------------------

fn feature_to_json(feature: Feature(p), to_json: fn(p) -> Json) -> Json {
  let base = [
    #("type", json.string("Feature")),
    #("geometry", geometry_to_json(feature.geometry)),
    #("properties", to_json(feature.properties)),
  ]
  let entries = case feature.id {
    None -> base
    Some(id) -> list.append(base, [#("id", id_to_json(id))])
  }
  json.object(entries)
}

fn id_to_json(id: FeatureId) -> Json {
  case id {
    StringId(value: value) -> json.string(value)
    IntId(value: value) -> json.int(value)
  }
}

// --- JSON → Geometry (decoder pipeline) ----------------------------------

fn parse_with(
  input: String,
  decoder: Decoder(Result(t, GeoJsonError)),
) -> Result(t, GeoJsonError) {
  case json.parse(from: input, using: decoder) {
    Error(err) -> Error(json_error_to_geojson(err))
    Ok(Ok(value)) -> Ok(value)
    Ok(Error(geojson_err)) -> Error(geojson_err)
  }
}

fn json_error_to_geojson(err: json.DecodeError) -> GeoJsonError {
  case err {
    json.UnexpectedEndOfInput -> InvalidJson("unexpected end of input")
    json.UnexpectedByte(byte) -> InvalidJson("unexpected byte: " <> byte)
    json.UnexpectedSequence(seq) -> InvalidJson("unexpected sequence: " <> seq)
    json.UnableToDecode(_) ->
      InvalidStructure("JSON shape did not match expected GeoJSON")
  }
}

fn geometry_decoder() -> Decoder(Result(Geometry, GeoJsonError)) {
  use type_ <- decode.field("type", decode.string)
  case type_ {
    "Point" -> {
      use coords <- decode.field("coordinates", decode.list(decode.float))
      decode.success(raw_point_to_geometry(coords))
    }
    "LineString" -> {
      use coords <- decode.field(
        "coordinates",
        decode.list(decode.list(decode.float)),
      )
      decode.success(raw_line_string_to_geometry(coords))
    }
    "Polygon" -> {
      use coords <- decode.field(
        "coordinates",
        decode.list(decode.list(decode.list(decode.float))),
      )
      decode.success(raw_polygon_to_geometry(coords))
    }
    "MultiPolygon" -> {
      use coords <- decode.field(
        "coordinates",
        decode.list(decode.list(decode.list(decode.list(decode.float)))),
      )
      decode.success(raw_multi_polygon_to_geometry(coords))
    }
    "MultiPoint" | "MultiLineString" | "GeometryCollection" ->
      decode.success(Error(UnsupportedType(type_)))
    other -> decode.success(Error(UnknownType(other)))
  }
}

fn raw_point_to_geometry(coords: List(Float)) -> Result(Geometry, GeoJsonError) {
  use point <- result.map(parse_position(coords))
  geometry.Point(point)
}

fn raw_line_string_to_geometry(
  coords: List(List(Float)),
) -> Result(Geometry, GeoJsonError) {
  use points <- result.map(list.try_map(coords, parse_position))
  geometry.LineString(points)
}

fn raw_polygon_to_geometry(
  coords: List(List(List(Float))),
) -> Result(Geometry, GeoJsonError) {
  use rings <- result.map(parse_rings(coords))
  geometry.Polygon(rings)
}

fn raw_multi_polygon_to_geometry(
  coords: List(List(List(List(Float)))),
) -> Result(Geometry, GeoJsonError) {
  use polygons <- result.map(list.try_map(coords, parse_rings))
  geometry.MultiPolygon(polygons)
}

fn parse_rings(
  raw: List(List(List(Float))),
) -> Result(List(List(LatLng)), GeoJsonError) {
  list.try_map(raw, fn(ring) { list.try_map(ring, parse_position) })
}

fn parse_position(coords: List(Float)) -> Result(LatLng, GeoJsonError) {
  case coords {
    [lng, lat] -> wrap_position(lng, lat)
    [lng, lat, _altitude] -> wrap_position(lng, lat)
    _ -> Error(InvalidPosition(coords))
  }
}

fn wrap_position(lng: Float, lat: Float) -> Result(LatLng, GeoJsonError) {
  case latlng.new(lat: lat, lng: lng) {
    Ok(p) -> Ok(p)
    Error(e) -> Error(InvalidLatLng(e))
  }
}

// --- JSON → Feature ------------------------------------------------------

fn feature_decoder(
  properties_decoder: Decoder(p),
) -> Decoder(Result(Feature(p), GeoJsonError)) {
  use type_ <- decode.field("type", decode.string)
  case type_ {
    "Feature" -> {
      use raw_geometry <- decode.field("geometry", geometry_decoder())
      use props <- decode.field("properties", properties_decoder)
      use id <- decode.optional_field("id", None, decode.optional(id_decoder()))
      decode.success(
        result.map(raw_geometry, fn(g) {
          Feature(geometry: g, properties: props, id: id)
        }),
      )
    }
    other -> {
      use _ <- decode.then(decode.success(Nil))
      decode.success(Error(UnknownType(other)))
    }
  }
}

fn id_decoder() -> Decoder(FeatureId) {
  decode.one_of(decode.map(decode.string, StringId), [
    decode.map(decode.int, IntId),
  ])
}

fn feature_collection_decoder(
  properties_decoder: Decoder(p),
) -> Decoder(Result(List(Feature(p)), GeoJsonError)) {
  use type_ <- decode.field("type", decode.string)
  case type_ {
    "FeatureCollection" -> {
      use features <- decode.field(
        "features",
        decode.list(feature_decoder(properties_decoder)),
      )
      decode.success(result.all(features))
    }
    other -> {
      use _ <- decode.then(decode.success(Nil))
      decode.success(Error(UnknownType(other)))
    }
  }
}