///|
/// Location: line and optional column in a text file (Cucumber Messages).
pub struct Location {
  line : Int
  column : Int?
} derive(ToJson, FromJson)

///|
/// Timestamp: seconds since Unix epoch + nanos (0..999_999_999).
/// `seconds` is an Int64 so that dates after 2038-01-19 fit.
pub struct Timestamp {
  seconds : Int64
  nanos : Int
}

///|
/// Duration: seconds + nanos (0..999_999_999).
pub struct Duration {
  seconds : Int64
  nanos : Int
}

///|
/// Decode an integral JSON number into Int64. The core `Int64` JSON
/// codec uses strings, but the protocol encodes `seconds` as a number.
fn seconds_from_json(
  json : Json,
  path : @json.JsonPath,
) -> Int64 raise @json.JsonDecodeError {
  guard json is Number(n, ..) && n == n.floor() else {
    raise @json.JsonDecodeError((path, "seconds: expected an integer number"))
  }
  n.to_int64()
}

///|
fn seconds_and_nanos_from_json(
  json : Json,
  path : @json.JsonPath,
  type_name : String,
) -> (Int64, Int) raise @json.JsonDecodeError {
  guard json is Object(obj) else {
    raise @json.JsonDecodeError(
      (path, "\{type_name}::from_json: expected object"),
    )
  }
  guard obj.get("seconds") is Some(s) else {
    raise @json.JsonDecodeError(
      (path, "\{type_name}::from_json: missing seconds"),
    )
  }
  guard obj.get("nanos") is Some(n) else {
    raise @json.JsonDecodeError(
      (path, "\{type_name}::from_json: missing nanos"),
    )
  }
  (
    seconds_from_json(s, path.add_key("seconds")),
    @json.from_json(n, path=path.add_key("nanos")),
  )
}

///|
pub impl @json.FromJson for Timestamp with fn from_json(json, path) {
  let (seconds, nanos) = seconds_and_nanos_from_json(json, path, "Timestamp")
  Timestamp::{ seconds, nanos, }
}

///|
pub impl ToJson for Timestamp with fn to_json(self) {
  { "seconds": Json::number(self.seconds.to_double()), "nanos": self.nanos }
}

///|
pub impl @json.FromJson for Duration with fn from_json(json, path) {
  let (seconds, nanos) = seconds_and_nanos_from_json(json, path, "Duration")
  Duration::{ seconds, nanos, }
}

///|
pub impl ToJson for Duration with fn to_json(self) {
  { "seconds": Json::number(self.seconds.to_double()), "nanos": self.nanos }
}

///|
/// Source media type for Source.mediaType.
pub enum SourceMediaType {
  GherkinPlain
  GherkinMarkdown
}

///|
pub impl @json.FromJson for SourceMediaType with fn from_json(json, path) {
  guard json is String(s) else {
    raise @json.JsonDecodeError(
      (path, "SourceMediaType::from_json: expected string"),
    )
  }
  match s {
    "text/x.cucumber.gherkin+plain" => GherkinPlain
    "text/x.cucumber.gherkin+markdown" => GherkinMarkdown
    _ =>
      raise @json.JsonDecodeError(
        (
          path, "SourceMediaType::from_json: expected text/x.cucumber.gherkin+plain or text/x.cucumber.gherkin+markdown",
        ),
      )
  }
}

///|
pub impl ToJson for SourceMediaType with fn to_json(self) {
  match self {
    GherkinPlain => "text/x.cucumber.gherkin+plain".to_json()
    GherkinMarkdown => "text/x.cucumber.gherkin+markdown".to_json()
  }
}

///|
test "location decode line only" {
  let json = @json.parse("{\"line\": 1}") catch { _ => panic() }
  let loc : Location = @json.from_json(json) catch { _ => panic() }
  assert_eq(loc.line, 1)
  assert_eq(loc.column, None)
}

///|
test "location decode line and column" {
  let json = @json.parse("{\"line\": 2, \"column\": 3}") catch { _ => panic() }
  let loc : Location = @json.from_json(json) catch { _ => panic() }
  assert_eq(loc.line, 2)
  assert_eq(loc.column, Some(3))
}

///|
test "location roundtrip" {
  let loc = Location::{ line: 5, column: Some(10), }
  let json = loc.to_json()
  let back : Location = @json.from_json(json) catch { _ => panic() }
  assert_eq(back.line, loc.line)
  assert_eq(back.column, loc.column)
}

///|
test "timestamp roundtrip" {
  let ts = Timestamp::{ seconds: 1234567890, nanos: 123456789, }
  let json = ts.to_json()
  let back : Timestamp = @json.from_json(json) catch { _ => panic() }
  assert_eq(back.seconds, ts.seconds)
  assert_eq(back.nanos, ts.nanos)
}

///|
test "duration roundtrip" {
  let d = Duration::{ seconds: 5, nanos: 500_000_000, }
  let json = d.to_json()
  let back : Duration = @json.from_json(json) catch { _ => panic() }
  assert_eq(back.seconds, d.seconds)
  assert_eq(back.nanos, d.nanos)
}

///|
test "timestamp after 2038 parses and serializes seconds as a number" {
  let json = @json.parse("{\"seconds\":4102444800,\"nanos\":5}")
  let ts : Timestamp = @json.from_json(json)
  assert_eq(ts.seconds, 4102444800L)
  assert_eq(ts.nanos, 5)
  assert_eq(ts.to_json().stringify(), "{\"seconds\":4102444800,\"nanos\":5}")
}

///|
test "duration with large seconds parses and serializes seconds as a number" {
  let json = @json.parse("{\"seconds\":3000000000,\"nanos\":0}")
  let d : Duration = @json.from_json(json)
  assert_eq(d.seconds, 3000000000L)
  assert_eq(d.to_json().stringify(), "{\"seconds\":3000000000,\"nanos\":0}")
}

///|
test "timestamp rejects fractional seconds" {
  let json = @json.parse("{\"seconds\":1.5,\"nanos\":0}")
  let ts : Timestamp? = Some(@json.from_json(json)) catch { _ => None }
  assert_true(ts is None)
}