///|
/// The Notion API client, ported from `src/Client.ts`.
///
/// The client owns request construction (URL, headers, body), authentication,
/// logging, retries and SSE parsing. It is transport-agnostic: supply a `fetch`
/// function in `ClientOptions` to connect an HTTP stack.

///|
/// Options for configuring a `Client`.
pub(all) struct ClientOptions {
  /// Public API integration token, used when a request does not pass `auth`.
  auth : String?
  /// Request timeout handed to the transport, in milliseconds.
  timeout_ms : Int
  /// Base URL of the API, without the `/v1/` suffix.
  base_url : String
  /// Minimum severity a message must have to be logged.
  log_level : LogLevel
  /// Sink for log messages; defaults to a console logger.
  logger : Logger?
  /// Value of the `Notion-Version` header.
  notion_version : String
  /// Transport. The SDK bundles no HTTP stack, so pass one to make requests.
  fetch : Fetch?
  /// `Some(false)` disables automatic retries entirely.
  retry : Bool?
  /// Maximum number of retry attempts.
  max_retries : Int
  /// Initial delay between retries in milliseconds.
  initial_retry_delay_ms : Int
  /// Maximum delay between retries in milliseconds.
  max_retry_delay_ms : Int
  /// Allow sending a token from a browser-like environment.
  dangerously_allow_browser : Bool
  /// Whether the client considers itself to be running in a browser. The
  /// TypeScript SDK sniffs `globalThis`; here it is an explicit option so the
  /// token guard remains testable.
  browser_environment : Bool
  /// Sleep function used between retries. Defaults to a no-op so retries can be
  /// tested without real delays.
  sleep : ((Int) -> Unit)?
  /// Source of jitter for retry back-off. Defaults to the core RNG.
  random : (() -> Double)?
}

///|
/// Builds `ClientOptions`, applying the SDK defaults.
pub fn ClientOptions::new(
  auth? : String? = None,
  timeout_ms? : Int = default_timeout_ms,
  base_url? : String = default_base_url,
  log_level? : LogLevel = Warn,
  logger? : Logger? = None,
  notion_version? : String = "2025-09-03",
  fetch? : Fetch? = None,
  retry? : Bool? = None,
  max_retries? : Int = default_max_retries,
  initial_retry_delay_ms? : Int = default_initial_retry_delay_ms,
  max_retry_delay_ms? : Int = default_max_retry_delay_ms,
  dangerously_allow_browser? : Bool = false,
  browser_environment? : Bool = false,
  sleep? : ((Int) -> Unit)? = None,
  random? : (() -> Double)? = None,
) -> ClientOptions {
  {
    auth,
    timeout_ms,
    base_url,
    log_level,
    logger,
    notion_version,
    fetch,
    retry,
    max_retries,
    initial_retry_delay_ms,
    max_retry_delay_ms,
    dangerously_allow_browser,
    browser_environment,
    sleep,
    random,
  }
}

///|
/// Authentication for a single request.
pub(all) enum RequestAuth {
  /// Bearer token (`auth` passed as a string in the TypeScript SDK).
  Bearer(String)
  /// OAuth client credentials (`auth` passed as an object).
  Basic(client_id~ : String, client_secret~ : String)
}

///|
/// A request to send.
pub(all) struct RequestParameters {
  path : String
  http_method : String
  query : Json?
  body : Json?
  form_data_params : Json?
  headers : Array[(String, String)]
  auth : RequestAuth?
}

///|
/// Builds `RequestParameters`.
pub fn RequestParameters::new(
  path~ : String,
  http_method~ : String,
  query? : Json? = None,
  body? : Json? = None,
  form_data_params? : Json? = None,
  headers? : Array[(String, String)] = [],
  auth? : RequestAuth? = None,
) -> RequestParameters {
  { path, http_method, query, body, form_data_params, headers, auth, }
}

///|
/// A parsed server-sent event frame.
pub(all) struct SseFrame {
  event_name : String
  data : String
}

///|
/// The result of consuming an SSE buffer.
pub(all) struct SseFrames {
  frames : Array[SseFrame]
  remaining : String
}

///|
/// The Notion API client.
pub struct Client {
  priv auth : String?
  priv log_level : LogLevel
  priv logger : Logger
  priv prefix_url : String
  priv timeout_ms : Int
  priv notion_version : String
  priv fetch : Fetch
  priv user_agent : String
  priv max_retries : Int
  priv initial_retry_delay_ms : Int
  priv max_retry_delay_ms : Int
  priv dangerously_allow_browser : Bool
  priv browser_environment : Bool
  priv sleep : (Int) -> Unit
  priv random : () -> Double
}

///|
/// The `Notion-Version` a client uses when none is configured.
pub let default_notion_version : String = "2025-09-03"

///|
/// Creates a client.
pub fn Client::new(options? : ClientOptions = ClientOptions::new()) -> Client {
  let retry_disabled = options.retry is Some(false)
  let rand = match options.random {
    Some(r) => r
    None => {
      let rng = @random.Rand::new()
      () => rng.double()
    }
  }
  {
    auth: options.auth,
    log_level: options.log_level,
    logger: match options.logger {
      Some(l) => l
      None => make_console_logger("notionhq-client")
    },
    prefix_url: options.base_url + "/v1/",
    timeout_ms: options.timeout_ms,
    notion_version: options.notion_version,
    fetch: match options.fetch {
      Some(f) => f
      None => default_fetch
    },
    user_agent: "notionhq-client/5.27.0",
    max_retries: if retry_disabled {
      0
    } else {
      options.max_retries
    },
    initial_retry_delay_ms: if retry_disabled {
      0
    } else {
      options.initial_retry_delay_ms
    },
    max_retry_delay_ms: if retry_disabled {
      0
    } else {
      options.max_retry_delay_ms
    },
    dangerously_allow_browser: options.dangerously_allow_browser,
    browser_environment: options.browser_environment,
    sleep: match options.sleep {
      Some(s) => s
      None => _ms => ()
    },
    random: rand,
  }
}

///|
/// Sends a request and returns the parsed JSON response body.
pub fn Client::request(self : Client, args : RequestParameters) -> Json raise {
  let { path, http_method, query, body, form_data_params, auth, headers, } = args
  // The TypeScript client rejects a configured token at construction time when
  // it detects a browser. Detection is an explicit option here, so the same
  // guard is applied before the first request leaves.
  if self.auth is Some(_) {
    self.assert_token_allowed_here()
  }
  validate_request_path(path)
  self.log(
    Info,
    "request start",
    Json::object(
      Map([
        ("http_method", Json::string(http_method)),
        ("path", Json::string(path)),
      ]),
    ),
  )
  let url = self.build_request_url(path, query)
  let body_as_json_string = serialize_body(body)
  let base_headers = self.build_request_headers(
    headers, auth, body_as_json_string,
  )
  let (request_headers, request_body) = match form_data_params {
    Some(params) =>
      match params {
        Json::Object(fields) if !fields.is_empty() =>
          build_multipart_form_data(params, base_headers, self.random)
        _ => (base_headers, body_as_json_string)
      }
    None => (base_headers, body_as_json_string)
  }
  self.execute_with_retry(url, path, http_method, request_headers, request_body)
}

///|
/// Opens an SSE response and returns each parsed event.
///
/// The TypeScript client streams frames as they arrive. This port reads the
/// transport's full body first and then splits it, which is equivalent for the
/// events the sessions stream delivers.
pub fn Client::stream_request(
  self : Client,
  args : RequestParameters,
  parse_event : (SseFrame) -> Json raise,
) -> Array[Json] raise {
  let { path, http_method, query, body, auth, headers, .. } = args
  validate_request_path(path)
  self.log(
    Info,
    "stream request start",
    Json::object(
      Map([
        ("http_method", Json::string(http_method)),
        ("path", Json::string(path)),
      ]),
    ),
  )
  let url = self.build_request_url(path, query)
  let body_as_json_string = serialize_body(body)
  let request_headers = self.build_request_headers(
    headers, auth, body_as_json_string,
  )
  let response = self.execute_with_retry_raw(
    url, http_method, request_headers, body_as_json_string,
  )
  self.log(
    Info,
    "stream request opened",
    Json::object(
      Map([
        ("http_method", Json::string(http_method)),
        ("path", Json::string(path)),
      ]),
    ),
  )
  let frames = take_sse_frames((response.text)(), true).frames
  let events : Array[Json] = []
  for frame in frames {
    events.push(parse_event(frame))
  }
  events
}

// ---------------------------------------------------------------------------
// Request construction
// ---------------------------------------------------------------------------

///|
/// Builds the full URL with query parameters.
fn Client::build_request_url(
  self : Client,
  path : String,
  query : Json?,
) -> String {
  let url = self.prefix_url + path
  let query_string = match query {
    Some(q) => build_query_string(q)
    None => ""
  }
  if query_string.is_empty() {
    url
  } else {
    url + "?" + query_string
  }
}

///|
/// Serializes the request body to a JSON string if non-empty.
fn serialize_body(body : Json?) -> String? {
  match body {
    None => None
    Some(Json::Object(fields)) => {
      let serialized : Map[String, Json] = Map([])
      fields.each((key, value) => {
        if key == "start_cursor" && value is Json::Null {
          ()
        } else {
          serialized.set(key, value)
        }
      })
      if serialized.is_empty() {
        None
      } else {
        Some(Json::object(serialized).stringify())
      }
    }
    Some(value) => Some(value.stringify())
  }
}

///|
/// Builds the request headers including auth and content-type.
fn Client::build_request_headers(
  self : Client,
  custom_headers : Array[(String, String)],
  auth : RequestAuth?,
  body_as_json_string : String?,
) -> Array[(String, String)] raise {
  let headers : Array[(String, String)] = []
  for pair in custom_headers {
    headers.push(pair)
  }
  for pair in self.build_auth_header(auth) {
    set_header(headers, pair.0, pair.1)
  }
  set_header(headers, "Notion-Version", self.notion_version)
  // Firefox and Safari send a custom user-agent, which the API's CORS preflight
  // does not allow, so browser requests omit it.
  if !self.browser_environment {
    set_header(headers, "user-agent", self.user_agent)
  }
  match body_as_json_string {
    Some(_) => set_header(headers, "content-type", "application/json")
    None => ()
  }
  headers
}

///|
/// Builds the authorization header based on the auth type.
fn Client::build_auth_header(
  self : Client,
  auth : RequestAuth?,
) -> Array[(String, String)] raise {
  match auth {
    None => self.auth_as_headers(None)
    Some(a) => {
      self.assert_token_allowed_here()
      match a {
        Bearer(token) => self.auth_as_headers(Some(token))
        Basic(client_id~, client_secret~) => {
          let credential = client_id + ":" + client_secret
          let encoded = @base64.encode(@utf8.encode(credential))
          [("authorization", "Basic " + encoded)]
        }
      }
    }
  }
}

///|
/// Turns an API key or access token into a headers array, falling back to the
/// client's configured token.
fn Client::auth_as_headers(
  self : Client,
  auth : String?,
) -> Array[(String, String)] {
  let value = match auth {
    Some(a) => Some(a)
    None => self.auth
  }
  match value {
    Some(token) => [("authorization", "Bearer " + token)]
    None => []
  }
}

///|
/// Replaces or appends a header (case-insensitive on the name).
fn set_header(
  headers : Array[(String, String)],
  name : String,
  value : String,
) -> Unit {
  let lower = name.to_lower()
  let mut found = false
  for i = 0; i < headers.length(); i = i + 1 {
    if headers[i].0.to_lower() == lower {
      headers[i] = (name, value)
      found = true
    }
  }
  if !found {
    headers.push((name, value))
  }
}

///|
/// Serializes a JSON object into a query string, skipping null values the same
/// way the TypeScript client's `URLSearchParams` loop does.
fn build_query_string(query : Json) -> String {
  match query {
    Json::Object(fields) => {
      let parts : Array[String] = []
      fields.each((key, value) => {
        match value {
          Json::Null => ()
          Json::Array(items) =>
            for item in items {
              parts.push(
                encode_uri_component(key) +
                "=" +
                encode_uri_component(scalar_to_string(item)),
              )
            }
          other =>
            parts.push(
              encode_uri_component(key) +
              "=" +
              encode_uri_component(scalar_to_string(other)),
            )
        }
      })
      parts.join("&")
    }
    _ => ""
  }
}

///|
/// Renders a scalar JSON value as the string a query parameter would carry.
fn scalar_to_string(value : Json) -> String {
  match value {
    Json::String(s) => s
    Json::Number(d, ..) => @endpoints.number_to_string(d)
    Json::True => "true"
    Json::False => "false"
    Json::Null => ""
    other => other.stringify()
  }
}

///|
/// Builds the request body and headers for a multipart/form-data request.
fn build_multipart_form_data(
  params : Json,
  headers : Array[(String, String)],
  random : () -> Double,
) -> (Array[(String, String)], String?) {
  let boundary = "----MoonBitNotionBoundary" +
    (random() * 1000000000.0).to_int().to_string()
  let out : StringBuilder = StringBuilder()
  match params {
    Json::Object(fields) =>
      fields.each((key, value) => {
        match value {
          Json::String(text) => {
            out.write_string("--" + boundary + "\r\n")
            out.write_string(
              "Content-Disposition: form-data; name=\"" + key + "\"\r\n\r\n",
            )
            out.write_string(text)
            out.write_string("\r\n")
          }
          Json::Object(parts) => {
            let filename = match parts.get("filename") {
              Some(Json::String(name)) => name
              _ => "file"
            }
            let data = match parts.get("data") {
              Some(Json::String(text)) => text
              Some(other) => other.stringify()
              None => ""
            }
            out.write_string("--" + boundary + "\r\n")
            out.write_string(
              "Content-Disposition: form-data; name=\"" +
              key +
              "\"; filename=\"" +
              filename +
              "\"\r\n",
            )
            out.write_string("Content-Type: application/octet-stream\r\n\r\n")
            out.write_string(data)
            out.write_string("\r\n")
          }
          Json::Number(d, ..) => {
            out.write_string("--" + boundary + "\r\n")
            out.write_string(
              "Content-Disposition: form-data; name=\"" + key + "\"\r\n\r\n",
            )
            out.write_string(@endpoints.number_to_string(d))
            out.write_string("\r\n")
          }
          _ => ()
        }
      })
    _ => ()
  }
  out.write_string("--" + boundary + "--\r\n")
  set_header(
    headers,
    "content-type",
    "multipart/form-data; boundary=" + boundary,
  )
  (headers, Some(out.to_string()))
}

///|
/// Percent-encodes a string the way `encodeURIComponent` does.
pub fn encode_uri_component(value : String) -> String {
  let bytes = @utf8.encode(value)
  let parts : Array[String] = []
  for byte in bytes {
    let code = byte.to_int()
    if is_unreserved(code) {
      parts.push(String::make(1, byte_to_char(code)))
    } else {
      parts.push("%" + hex_byte(byte))
    }
  }
  parts.join("")
}

///|
/// Whether a byte is an `encodeURIComponent` unreserved character.
fn is_unreserved(code : Int) -> Bool {
  (code >= 65 && code <= 90) || // A-Z
  (code >= 97 && code <= 122) || // a-z
  (code >= 48 && code <= 57) || // 0-9
  code == 45 || // -
  code == 95 || // _
  code == 46 || // .
  code == 33 || // !
  code == 126 || // ~
  code == 42 || // *
  code == 39 || // '
  code == 40 || // (
  code == 41 // )
}

///|
/// Converts an ASCII byte code to a character.
fn byte_to_char(code : Int) -> Char {
  code.unsafe_to_char()
}

///|
/// Renders a byte as two lower-case hex digits.
fn hex_byte(byte : Byte) -> String {
  let digits = "0123456789ABCDEF"
  let value = byte.to_int()
  let hi = value / 16
  let lo = value % 16
  digits[hi:hi + 1].to_owned() + digits[lo:lo + 1].to_owned()
}

// ---------------------------------------------------------------------------
// Execution, retries and logging
// ---------------------------------------------------------------------------

///|
/// Executes the request with retry logic.
fn Client::execute_with_retry(
  self : Client,
  url : String,
  path : String,
  http_method : String,
  headers : Array[(String, String)],
  body : String?,
) -> Json raise {
  let mut attempt = 0
  while true {
    try {
      return self.execute_single_request(url, path, http_method, headers, body)
    } catch {
      NotionClientError(e) => {
        self.log_request_error(e, attempt)
        if attempt < self.max_retries && can_retry(e, http_method) {
          let delay_ms = self.calculate_retry_delay(e, attempt)
          self.log(
            Info,
            "retrying request",
            Json::object(
              Map([
                ("http_method", Json::string(http_method)),
                ("path", Json::string(path)),
                ("attempt", Json::number((attempt + 1).to_double())),
                ("delayMs", Json::number(delay_ms.to_double())),
              ]),
            ),
          )
          (self.sleep)(delay_ms)
          attempt = attempt + 1
          continue
        } else {
          raise NotionClientError(e)
        }
      }
      other => raise other
    }
  } nobreak {
    Json::null()
  }
}

///|
/// Executes the request with retry logic, returning the raw response. Used by
/// the streaming path, which needs the response before reading its body.
fn Client::execute_with_retry_raw(
  self : Client,
  url : String,
  http_method : String,
  headers : Array[(String, String)],
  body : String?,
) -> HttpResponse raise {
  let mut attempt = 0
  while true {
    try {
      return self.execute_single_request_raw(url, http_method, headers, body)
    } catch {
      NotionClientError(e) => {
        self.log_request_error(e, attempt)
        if attempt < self.max_retries && can_retry(e, http_method) {
          let delay_ms = self.calculate_retry_delay(e, attempt)
          (self.sleep)(delay_ms)
          attempt = attempt + 1
          continue
        } else {
          raise NotionClientError(e)
        }
      }
      other => raise other
    }
  } nobreak {
    HttpResponse::from_text(status=0, headers=[], body="")
  }
}

///|
/// Executes a single HTTP request (no retry).
fn Client::execute_single_request(
  self : Client,
  url : String,
  path : String,
  http_method : String,
  headers : Array[(String, String)],
  body : String?,
) -> Json raise {
  let response = self.execute_single_request_raw(
    url, http_method, headers, body,
  )
  let response_text = (response.text)()
  let response_json : Json = @json.parse(response_text) catch {
    _ =>
      raise NotionClientError(
        unknown_http_response_error(
          status=response.status,
          headers=response.headers,
          body=response_text,
          message=Some("Failed to parse the Notion API response as JSON."),
        ),
      )
  }
  self.log(
    Info,
    "request success",
    Json::object(
      Map([
        ("http_method", Json::string(http_method)),
        ("path", Json::string(path)),
        ("requestId", json_request_id(response_json)),
      ]),
    ),
  )
  response_json
}

///|
/// Performs the transport call and turns non-ok responses into errors.
fn Client::execute_single_request_raw(
  self : Client,
  url : String,
  http_method : String,
  headers : Array[(String, String)],
  body : String?,
) -> HttpResponse raise {
  let response = (self.fetch)(url, {
    http_method: http_method.to_upper(),
    headers,
    body,
    timeout_ms: self.timeout_ms,
    agent: None,
  })
  if !response.ok {
    raise build_request_error(response, (response.text)())
  }
  response
}

///|
/// Logs a request error with the appropriate detail level.
fn Client::log_request_error(
  self : Client,
  error : NotionError,
  attempt : Int,
) -> Unit {
  self.log(
    Warn,
    "request fail",
    Json::object(
      Map([
        ("code", Json::string(error.code)),
        ("message", Json::string(error.message)),
        ("attempt", Json::number(attempt.to_double())),
        (
          "requestId",
          match error.request_id {
            Some(id) => Json::string(id)
            None => Json::null()
          },
        ),
      ]),
    ),
  )
  if error.kind is ApiResponseError || error.kind is UnknownHTTPResponseError {
    self.log(
      Debug,
      "failed response body",
      Json::object(Map([("body", Json::string(error.body))])),
    )
  }
}

///|
/// Extracts `request_id` from a response object if present.
fn json_request_id(obj : Json) -> Json {
  match obj {
    Json::Object(fields) =>
      match fields.get("request_id") {
        Some(Json::String(id)) => Json::string(id)
        _ => Json::null()
      }
    _ => Json::null()
  }
}

///|
/// Determines if an error can be retried based on its error code and method.
///
/// Rate limits (429) and service overloads (529) are always retryable since the
/// server explicitly asks us to retry. Server errors (500, 503) are only
/// retried for idempotent methods (GET, DELETE) to avoid duplicate side effects.
pub fn can_retry(error : NotionError, http_method : String) -> Bool {
  if !(error.kind is ApiResponseError) {
    return false
  }
  let code = error.code
  if code == "rate_limited" || code == "service_overload" {
    return true
  }
  let lower = http_method.to_lower()
  let is_idempotent = lower == "get" || lower == "delete"
  if is_idempotent {
    return code == "internal_server_error" || code == "service_unavailable"
  }
  false
}

///|
/// Calculates the delay before the next retry attempt.
fn Client::calculate_retry_delay(
  self : Client,
  error : NotionError,
  attempt : Int,
) -> Int {
  if error.kind is ApiResponseError {
    match parse_retry_after_header(error.headers) {
      Some(retry_after_ms) => return retry_after_ms.min(self.max_retry_delay_ms)
      None => ()
    }
  }
  let base_delay = self.initial_retry_delay_ms.to_double() *
    @math.pow(2.0, attempt.to_double())
  let jitter = (self.random)()
  (base_delay * jitter + base_delay / 2.0).to_int().min(self.max_retry_delay_ms)
}

///|
/// Parses the `retry-after` header value in delta-seconds form and returns the
/// delay in milliseconds. HTTP-date values are not supported by this port.
pub fn parse_retry_after_header(headers : Array[(String, String)]) -> Int? {
  match get_response_header(headers, "retry-after") {
    None => None
    Some(value) => {
      let seconds = @string.parse_int(value.trim()) catch { _ => -1 }
      if seconds >= 0 {
        Some(seconds * 1000)
      } else {
        None
      }
    }
  }
}

///|
/// Emits a log message if the level passes the client's threshold.
fn Client::log(
  self : Client,
  level : LogLevel,
  message : String,
  extra_info : Json,
) -> Unit {
  if log_level_severity(level) >= log_level_severity(self.log_level) {
    (self.logger)(level, message, extra_info)
  }
}

///|
/// Blocks a token or client secret inside a browser unless the caller opted in.
fn Client::assert_token_allowed_here(self : Client) -> Unit raise {
  if self.dangerously_allow_browser || !self.browser_environment {
    return
  }
  raise NotionClientError(browser_token_not_allowed_error())
}

// ---------------------------------------------------------------------------
// Server-sent events
// ---------------------------------------------------------------------------

///|
/// Event types the sessions stream can carry.
pub let session_stream_event_types : Array[String] = [
  "session.snapshot", "event.provisional", "event.committed", "stream.timeout", "stream.end",
  "stream.error",
]

///|
/// Splits an SSE buffer into complete frames, returning any trailing partial
/// frame so the caller can prepend it to the next chunk.
pub fn take_sse_frames(content : String, complete : Bool) -> SseFrames raise {
  let frames : Array[SseFrame] = []
  let mut remaining = content.replace_all(old="\r\n", new="\n")
  let mut boundary = remaining.find("\n\n")
  while boundary is Some(index) {
    match parse_sse_frame(remaining[:index].to_owned()) {
      Some(frame) => frames.push(frame)
      None => ()
    }
    remaining = remaining[index + 2:].to_owned()
    boundary = remaining.find("\n\n")
  }
  if complete && !remaining.is_empty() {
    match parse_sse_frame(remaining) {
      Some(frame) => frames.push(frame)
      None => ()
    }
    remaining = ""
  }
  { frames, remaining, }
}

///|
/// Parses a single SSE frame. Returns `None` for frames without a data field,
/// such as comments or keep-alives.
pub fn parse_sse_frame(content : String) -> SseFrame? raise {
  let mut event_name : String? = None
  let data_lines : Array[String] = []
  for line in content.split("\n") {
    if line.has_prefix(":") {
      continue
    }
    match line.find(":") {
      None => continue
      Some(colon_index) => {
        let field = line[:colon_index].to_owned()
        let value_start = if line[colon_index + 1:].has_prefix(" ") {
          colon_index + 2
        } else {
          colon_index + 1
        }
        let value = line[value_start:].to_owned()
        if field == "event" {
          event_name = Some(value)
        } else if field == "data" {
          data_lines.push(value)
        }
      }
    }
  }
  if data_lines.is_empty() {
    return None
  }
  match event_name {
    None =>
      raise NotionClientError(
        NotionError::new(
          kind=UnknownHTTPResponseError,
          code=ClientErrorCode::ResponseError.to_string(),
          message="Session stream event is missing its SSE event name.",
        ),
      )
    Some(name) => Some({ event_name: name, data: data_lines.join("\n"), })
  }
}

///|
/// Parses a session stream event and checks it against its SSE event name.
pub fn parse_session_stream_event(frame : SseFrame) -> Json raise {
  let event : Json = @json.parse(frame.data) catch {
    _ =>
      raise NotionClientError(
        NotionError::new(
          kind=UnknownHTTPResponseError,
          code=ClientErrorCode::ResponseError.to_string(),
          message="Session stream event body is not valid JSON.",
        ),
      )
  }
  let event_type = match event {
    Json::Object(fields) =>
      match fields.get("type") {
        Some(Json::String(t)) => t
        _ => ""
      }
    _ => ""
  }
  if !session_stream_event_types.contains(event_type) ||
    event_type != frame.event_name {
    raise NotionClientError(
      NotionError::new(
        kind=UnknownHTTPResponseError,
        code=ClientErrorCode::ResponseError.to_string(),
        message="Session stream event does not match its SSE event name.",
      ),
    )
  }
  event
}