///|
/// Envelope: exactly one message variant per JSON object.
pub enum Envelope {
  Meta(Meta)
  Source(Source)
  ParseError(ParseError)
  TestRunStarted(TestRunStarted)
  TestRunFinished(TestRunFinished)
  UndefinedParameterType(UndefinedParameterType)
  TestCaseStarted(TestCaseStarted)
  TestCaseFinished(TestCaseFinished)
  TestStepStarted(TestStepStarted)
  TestStepFinished(TestStepFinished)
  Attachment(Attachment)
  ExternalAttachment(ExternalAttachment)
  Hook(Hook)
  StepDefinition(StepDefinition)
  ParameterType(ParameterType)
  Suggestion(Suggestion)
  TestRunHookStarted(TestRunHookStarted)
  TestRunHookFinished(TestRunHookFinished)
  GherkinDocument(GherkinDocument)
  Pickle(Pickle)
  TestCase(TestCase)
  /// A message type this library does not know, for example one added by a
  /// newer protocol version. Holds the message key and its JSON value
  /// unchanged, so it serializes back to the same JSON.
  UnknownMessage(String, Json)
}

///|
pub impl @json.FromJson for Envelope with fn from_json(json, path) {
  guard json is Object(obj) else {
    raise @json.JsonDecodeError((path, "Envelope::from_json: expected object"))
  }
  let mut found : (String, Envelope)? = None
  for key, value in obj {
    let envelope = match decode_message(key, value, path.add_key(key)) {
      Some(envelope) => envelope
      None => UnknownMessage(key, value)
    }
    if found is Some((first, _)) {
      raise @json.JsonDecodeError(
        (
          path,
          "Envelope::from_json: more than one message key: \{first}, \{key}",
        ),
      )
    }
    found = Some((key, envelope))
  }
  guard found is Some((_, envelope)) else {
    raise @json.JsonDecodeError(
      (path, "Envelope::from_json: no message key found"),
    )
  }
  envelope
}

///|
let known_message_keys : Set[String] = Set::from_array([
  "meta", "source", "parseError", "testRunStarted", "testRunFinished", "undefinedParameterType",
  "testCaseStarted", "testCaseFinished", "testStepStarted", "testStepFinished", "attachment",
  "externalAttachment", "hook", "stepDefinition", "parameterType", "suggestion",
  "testRunHookStarted", "testRunHookFinished", "gherkinDocument", "pickle", "testCase",
])

///|
/// Decode the message stored under `key`, or `None` when `key` is not a
/// known message type. Only known messages are normalized.
fn decode_message(
  key : String,
  value : Json,
  path : @json.JsonPath,
) -> Envelope? raise @json.JsonDecodeError {
  guard known_message_keys.contains(key) else { return None }
  let value = normalize_envelope_json(value, path)
  let envelope = match key {
    "meta" => Meta(@json.from_json(value, path~))
    "source" => Source(@json.from_json(value, path~))
    "parseError" => ParseError(@json.from_json(value, path~))
    "testRunStarted" => TestRunStarted(@json.from_json(value, path~))
    "testRunFinished" => TestRunFinished(@json.from_json(value, path~))
    "undefinedParameterType" =>
      UndefinedParameterType(@json.from_json(value, path~))
    "testCaseStarted" => TestCaseStarted(@json.from_json(value, path~))
    "testCaseFinished" => TestCaseFinished(@json.from_json(value, path~))
    "testStepStarted" => TestStepStarted(@json.from_json(value, path~))
    "testStepFinished" => TestStepFinished(@json.from_json(value, path~))
    "attachment" => Attachment(@json.from_json(value, path~))
    "externalAttachment" => ExternalAttachment(@json.from_json(value, path~))
    "hook" => Hook(@json.from_json(value, path~))
    "stepDefinition" => StepDefinition(@json.from_json(value, path~))
    "parameterType" => ParameterType(@json.from_json(value, path~))
    "suggestion" => Suggestion(@json.from_json(value, path~))
    "testRunHookStarted" => TestRunHookStarted(@json.from_json(value, path~))
    "testRunHookFinished" => TestRunHookFinished(@json.from_json(value, path~))
    "gherkinDocument" => GherkinDocument(@json.from_json(value, path~))
    "pickle" => Pickle(@json.from_json(value, path~))
    "testCase" => TestCase(@json.from_json(value, path~))
    _ => return None
  }
  Some(envelope)
}

///|
pub impl ToJson for Envelope with fn to_json(self) {
  let o : Map[String, Json] = match self {
    Meta(m) => { "meta": m.to_json() }
    Source(s) => { "source": s.to_json() }
    ParseError(p) => { "parseError": p.to_json() }
    TestRunStarted(t) => { "testRunStarted": t.to_json() }
    TestRunFinished(t) => { "testRunFinished": t.to_json() }
    UndefinedParameterType(u) => { "undefinedParameterType": u.to_json() }
    TestCaseStarted(t) => { "testCaseStarted": t.to_json() }
    TestCaseFinished(t) => { "testCaseFinished": t.to_json() }
    TestStepStarted(t) => { "testStepStarted": t.to_json() }
    TestStepFinished(t) => { "testStepFinished": t.to_json() }
    Attachment(a) => { "attachment": a.to_json() }
    ExternalAttachment(e) => { "externalAttachment": e.to_json() }
    Hook(h) => { "hook": h.to_json() }
    StepDefinition(s) => { "stepDefinition": s.to_json() }
    ParameterType(p) => { "parameterType": p.to_json() }
    Suggestion(s) => { "suggestion": s.to_json() }
    TestRunHookStarted(t) => { "testRunHookStarted": t.to_json() }
    TestRunHookFinished(t) => { "testRunHookFinished": t.to_json() }
    GherkinDocument(g) => { "gherkinDocument": g.to_json() }
    Pickle(p) => { "pickle": p.to_json() }
    TestCase(t) => { "testCase": t.to_json() }
    UnknownMessage(key, value) => Map::from_array([(key, value)])
  }
  o.to_json()
}

///|
test "envelope meta decode and roundtrip" {
  let raw = "{\"meta\":{\"protocolVersion\":\"27.0.0\",\"implementation\":{\"name\":\"x\"},\"runtime\":{\"name\":\"y\"},\"os\":{\"name\":\"z\"},\"cpu\":{\"name\":\"w\"}}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    Meta(m) => {
      assert_eq(m.protocolVersion, "27.0.0")
      assert_eq(m.implementation.name, "x")
    }
    _ => panic()
  }
  let json2 = env.to_json()
  let back : Envelope = @json.from_json(json2) catch { _ => panic() }
  match (env, back) {
    (Meta(m), Meta(m2)) => assert_eq(m.protocolVersion, m2.protocolVersion)
    _ => panic()
  }
}

///|
test "envelope source roundtrip" {
  let raw = "{\"source\":{\"uri\":\"a.feature\",\"data\":\"Feature: y\",\"mediaType\":\"text/x.cucumber.gherkin+markdown\"}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    Source(s) => assert_eq(s.uri, "a.feature")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    Source(s2) => assert_eq(s2.uri, "a.feature")
    _ => panic()
  }
}

///|
test "envelope attachment roundtrip" {
  let raw = "{\"attachment\":{\"body\":\"data\",\"contentEncoding\":\"BASE64\",\"mediaType\":\"image/png\"}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    Attachment(a) => assert_eq(a.mediaType, "image/png")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    Attachment(a) => assert_eq(a.body, "data")
    _ => panic()
  }
}

///|
test "envelope external attachment roundtrip" {
  let raw = "{\"externalAttachment\":{\"url\":\"https://example.com/f.txt\",\"mediaType\":\"text/plain\"}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    ExternalAttachment(e) => assert_eq(e.url, "https://example.com/f.txt")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    ExternalAttachment(e) => assert_eq(e.mediaType, "text/plain")
    _ => panic()
  }
}

///|
test "envelope hook roundtrip" {
  let raw = "{\"hook\":{\"id\":\"h2\",\"sourceReference\":{}}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    Hook(h) => assert_eq(h.id, "h2")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    Hook(h) => assert_eq(h.id, "h2")
    _ => panic()
  }
}

///|
test "envelope step definition roundtrip" {
  let raw = "{\"stepDefinition\":{\"id\":\"sd2\",\"pattern\":{\"source\":\"^given$\",\"type\":\"REGULAR_EXPRESSION\"},\"sourceReference\":{}}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    StepDefinition(sd) => assert_eq(sd.id, "sd2")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    StepDefinition(sd) => assert_eq(sd.id, "sd2")
    _ => panic()
  }
}

///|
test "envelope parameter type roundtrip" {
  let raw = "{\"parameterType\":{\"id\":\"pt2\",\"name\":\"int\",\"preferForRegularExpressionMatch\":false,\"regularExpressions\":[\"\\\\d+\"],\"useForSnippets\":true}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    ParameterType(pt) => assert_eq(pt.name, "int")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    ParameterType(pt) => assert_eq(pt.name, "int")
    _ => panic()
  }
}

///|
test "envelope suggestion roundtrip" {
  let raw = "{\"suggestion\":{\"id\":\"sug2\",\"pickleStepId\":\"ps2\",\"snippets\":[{\"language\":\"ruby\",\"code\":\"Given\"}]}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    Suggestion(s) => assert_eq(s.id, "sug2")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    Suggestion(s) => assert_eq(s.snippets[0].language, "ruby")
    _ => panic()
  }
}

///|
test "envelope test run hook started roundtrip" {
  let raw = "{\"testRunHookStarted\":{\"id\":\"trhs2\",\"testRunStartedId\":\"trs2\",\"hookId\":\"h2\",\"timestamp\":{\"seconds\":200,\"nanos\":0}}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    TestRunHookStarted(t) => assert_eq(t.id, "trhs2")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    TestRunHookStarted(t) => assert_eq(t.hookId, "h2")
    _ => panic()
  }
}

///|
test "envelope test run hook finished roundtrip" {
  let raw = "{\"testRunHookFinished\":{\"testRunHookStartedId\":\"trhs3\",\"result\":{\"duration\":{\"seconds\":0,\"nanos\":500},\"status\":\"FAILED\"},\"timestamp\":{\"seconds\":300,\"nanos\":0}}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    TestRunHookFinished(t) => assert_eq(t.testRunHookStartedId, "trhs3")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    TestRunHookFinished(t) =>
      match t.result.status {
        Failed => ()
        _ => panic()
      }
    _ => panic()
  }
}

///|
test "envelope gherkin document roundtrip" {
  let raw = "{\"gherkinDocument\":{\"comments\":[],\"feature\":{\"location\":{\"line\":1},\"tags\":[],\"language\":\"en\",\"keyword\":\"Feature\",\"name\":\"X\",\"description\":\"\",\"children\":[]}}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    GherkinDocument(doc) => {
      guard! doc.feature is Some(f)
      assert_eq(f.name, "X")
    }
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    GherkinDocument(_) => ()
    _ => panic()
  }
}

///|
test "envelope pickle roundtrip" {
  let raw = "{\"pickle\":{\"id\":\"p2\",\"uri\":\"a.feature\",\"name\":\"Q\",\"language\":\"en\",\"steps\":[],\"tags\":[],\"astNodeIds\":[\"x\"]}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    Pickle(p) => assert_eq(p.name, "Q")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    Pickle(p) => assert_eq(p.id, "p2")
    _ => panic()
  }
}

///|
test "envelope test case roundtrip" {
  let raw = "{\"testCase\":{\"id\":\"tc3\",\"pickleId\":\"p3\",\"testSteps\":[{\"id\":\"ts3\"}]}}"
  let json = @json.parse(raw) catch { _ => panic() }
  let env : Envelope = @json.from_json(json) catch { _ => panic() }
  match env {
    TestCase(tc) => assert_eq(tc.id, "tc3")
    _ => panic()
  }
  let back : Envelope = @json.from_json(env.to_json()) catch { _ => panic() }
  match back {
    TestCase(tc) => assert_eq(tc.pickleId, "p3")
    _ => panic()
  }
}

///|
test "envelope treats null optional fields as absent" {
  let raw =
    #|{"meta":{"protocolVersion":"34.2.1","implementation":{"name":"x","version":null},"runtime":{"name":"x"},"os":{"name":"x"},"cpu":{"name":"x"},"ci":null}}
  guard! parse_ndjson_line(raw) is Meta(m)
  assert_eq(m.ci is None, true)
  assert_eq(m.implementation.version, None)
  assert_eq(
    m.to_json().stringify(),
    "{\"protocolVersion\":\"34.2.1\",\"implementation\":{\"name\":\"x\"},\"runtime\":{\"name\":\"x\"},\"os\":{\"name\":\"x\"},\"cpu\":{\"name\":\"x\"}}",
  )
}

///|
test "envelope treats null in a nested array element as absent" {
  let raw =
    #|{"testCase":{"id":"tc","pickleId":"p","testSteps":[{"id":"s","hookId":null,"pickleStepId":"ps","stepDefinitionIds":["sd"],"stepMatchArgumentsLists":null}],"testRunStartedId":null}}
  guard! parse_ndjson_line(raw) is TestCase(tc)
  assert_eq(tc.testSteps[0].hookId, None)
  assert_eq(tc.testRunStartedId, None)
}

///|
test "envelope reports null on a required field as missing" {
  let raw =
    #|{"testRunStarted":{"timestamp":null}}
  let message = try parse_ndjson_line(raw) catch {
    e => e.to_string()
  } noraise {
    _ => "no error"
  }
  assert_true(message.contains("Missing field timestamp"))
}

///|
test "envelope rejects fractional numbers" {
  for
    raw in [
      "{\"parseError\":{\"source\":{\"uri\":\"a\",\"location\":{\"line\":1.5}},\"message\":\"m\"}}",
      "{\"testCaseStarted\":{\"attempt\":0.5,\"id\":\"i\",\"testCaseId\":\"t\",\"timestamp\":{\"seconds\":1,\"nanos\":0}}}",
    ] {
    let message = try parse_ndjson_line(raw) catch {
      e => e.to_string()
    } noraise {
      _ => "no error"
    }
    assert_true(
      message.contains("expected an integer"),
      msg="\{raw}: \{message}",
    )
  }
}

///|
test "envelope with two message keys is rejected" {
  let raw =
    #|{"testRunStarted":{"timestamp":{"seconds":1,"nanos":0}},"testRunFinished":{"success":true,"timestamp":{"seconds":1,"nanos":0}}}
  let message = try parse_ndjson_line(raw) catch {
    e => e.to_string()
  } noraise {
    _ => "no error"
  }
  assert_true(message.contains("more than one message"), msg=message)
}

///|
test "envelope keeps an unknown message type" {
  let raw =
    #|{"futureMessage":{"id":"f1","ratio":0.5,"note":null,"items":[1,2]}}
  let envelope = parse_ndjson_line(raw)
  guard! envelope is UnknownMessage(key, value)
  assert_eq(key, "futureMessage")
  guard! @json.parse(raw) is Object(obj)
  assert_eq(Some(value), obj.get("futureMessage"))
  assert_eq(envelope.to_json(), @json.parse(raw))
}

///|
test "envelope with a known and an unknown message key is rejected" {
  let raw =
    #|{"testRunStarted":{"timestamp":{"seconds":1,"nanos":0}},"futureMessage":{}}
  let message = try parse_ndjson_line(raw) catch {
    e => e.to_string()
  } noraise {
    _ => "no error"
  }
  assert_true(message.contains("more than one message"), msg=message)
}

///|
test "empty envelope is rejected" {
  let message = try parse_ndjson_line("{}") catch {
    e => e.to_string()
  } noraise {
    _ => "no error"
  }
  assert_true(message.contains("no message"), msg=message)
}

///|
test "unknown fields inside a known message are ignored" {
  let raw =
    #|{"testRunStarted":{"timestamp":{"seconds":1,"nanos":0},"futureField":{"a":1}}}
  let envelope = parse_ndjson_line(raw)
  assert_eq(
    envelope.to_json().stringify(),
    "{\"testRunStarted\":{\"timestamp\":{\"seconds\":1,\"nanos\":0}}}",
  )
}