///|
/// SSE (Server-Sent Events) scanner for streamable HTTP transports.
///
/// `SseEventReader` consumes one event per `next` call — or detects EOF —
/// from a streaming HTTP response body. Tokenization runs through `lexscan`
/// over an `@lexbuf.AsyncLexbuf`, so the per-line matching stays on the
/// compiler-optimized regex path and patterns may span refill-chunk
/// boundaries. The lexbuf persists across `next` calls: when one refill
/// chunk carries a keepalive event plus the start of the next event, the
/// leftover bytes stay buffered and are scanned by the following `next`,
/// instead of being discarded with a per-call lexbuf.
///
/// Semantics follow the WHATWG SSE spec: lines end with `\n`, `\r\n`, or
/// `\r`; a blank line dispatches the accumulated event; `data:`/`id:` values
/// strip a single leading space; comments and unknown fields are ignored;
/// consecutive `data:` lines join with `\n`; an unterminated final line at
/// EOF is dispatched as if the terminator were present.
pub struct SseEventReader {
  buf : @lexbuf.AsyncLexbuf
  decode_failed : Ref[Bool]
}

///|
/// Build a reader over one streaming response body. The refill closure is
/// created once here; every `next` call scans the same lexbuf it fills.
pub fn SseEventReader::new(client : @http.Client) -> SseEventReader {
  // Malformed UTF-8 cannot be raised through `AsyncLexbuf`'s fixed generic
  // refill signature, so the closure flags the failure and reports EOF to
  // stop the scan; `next` raises the error after scanning unwinds.
  let decode_failed = Ref(false)
  let buf = @lexbuf.AsyncLexbuf::from_fn(() => {
    let chunk = client.read_some(max_len=8192)
    match chunk {
      Some(bytes) =>
        Some(@utf8.decode(bytes)) catch {
          _ => {
            decode_failed.val = true
            None
          }
        }
      None => None
    }
  })
  { buf, decode_failed, }
}

///|
/// Scan the next event from the response stream, reusing the reader's
/// persistent lexbuf so bytes buffered by earlier `next` calls are not lost.
pub async fn SseEventReader::next(
  self : SseEventReader,
) -> (String?, String?) raise @types.TransportError {
  let scanned = scan_sse_event(self.buf, [], None) catch { _ => (None, None) }
  if self.decode_failed.val {
    self.decode_failed.val = false
    raise @types.ReadError("SSE body is not valid UTF-8")
  }
  scanned
}

///|
/// One-shot wrapper over `SseEventReader`: consume exactly one event from a
/// streaming response body. Looping consumers should build one reader per
/// response stream and call `next` repeatedly instead — each `read_sse_event`
/// call starts a fresh lexbuf, dropping any trailing bytes buffered from its
/// own scan.
pub async fn read_sse_event(
  client : @http.Client,
) -> (String?, String?) raise @types.TransportError {
  SseEventReader::new(client).next()
}

///|
/// What a response-stream consumer should do with one scanned SSE event.
pub enum SseEventVerdict {
  /// A JSON-RPC payload to queue for `receive`.
  Message(String)
  /// A keepalive/heartbeat event (dispatched but carrying no payload) —
  /// keep scanning, the stream is still open.
  Skip
  /// End of stream.
  Eof
} derive(Eq, @debug.Debug)

///|
/// Classify one `read_sse_event` result for a response stream.
///
/// Servers may open or interleave keepalive events: an event dispatched with
/// an empty or whitespace-only `data:` payload (some servers open every
/// stream with `data:` (empty) + `id:` + `retry:` heartbeat events), or a
/// comment-only heartbeat that
/// carries an `id` but no data lines. None of those are JSON-RPC messages —
/// queueing an empty payload desyncs response matching (initialize would
/// parse the empty string), and treating a heartbeat as EOF ends the scan
/// before the real reply arrives. A heartbeat with neither data nor id is
/// indistinguishable from EOF by the scan result and still ends the stream.
pub fn sse_event_verdict(data : String?, id : String?) -> SseEventVerdict {
  match data {
    Some(event_json) =>
      if event_json.trim().length() > 0 {
        Message(event_json)
      } else {
        Skip
      }
    None => if id is Some(_) { Skip } else { Eof }
  }
}

///|
/// Scan one event from `buf`, accumulating `data` lines and the last `id`
/// seen. The recursion is the loop: one arm per SSE line kind, one
/// terminating arm for the blank dispatch line, and the catch-all for EOF.
async fn scan_sse_event(
  buf : @lexbuf.AsyncLexbuf,
  data : Array[StringView],
  event_id : String?,
) -> (String?, String?) {
  lexscan buf {
    re"^data:[^\n\r]*(\r\n|\r|\n)?" as line => {
      data.push(sse_field_value(line, field="data:"))
      scan_sse_event(buf, data, event_id)
    }
    re"^id:[^\n\r]*(\r\n|\r|\n)?" as line =>
      scan_sse_event(
        buf,
        data,
        Some(sse_field_value(line, field="id:").to_owned()),
      )
    // Blank line: dispatch what has accumulated. Comment-only events
    // (heartbeats) carry the id but no payload.
    re"^(\r\n|\r|\n)" => (join_sse_data(data), event_id)
    // Comment line.
    re"^:[^\n\r]*(\r\n|\r|\n)?" => scan_sse_event(buf, data, event_id)
    // Unknown field line.
    re"^[^\n\r]+(\r\n|\r|\n)?" => scan_sse_event(buf, data, event_id)
    // EOF: flush a partially accumulated event, per spec.
    _ => (join_sse_data(data), event_id)
  }
}

///|
/// Reduce a matched `data:`/`id:` line to its field value: drop the field
/// name, at most one leading space, and the trailing line terminator.
fn sse_field_value(matched : StringView, field~ : String) -> StringView {
  let body = drop_sse_eol(matched[field.length():])
  if body is [' ', .. rest] {
    rest
  } else {
    body
  }
}

///|
/// Drop the trailing `\r\n`, `\n`, or `\r` from a fully matched line, if any.
fn drop_sse_eol(line : StringView) -> StringView {
  if line.has_suffix("\r\n") {
    line[:line.length() - 2]
  } else if line.has_suffix("\n") || line.has_suffix("\r") {
    line[:line.length() - 1]
  } else {
    line
  }
}

///|
/// Join accumulated `data` values with `\n`; an event without `data` lines
/// has no payload.
fn join_sse_data(data : Array[StringView]) -> String? {
  if data.is_empty() {
    None
  } else {
    Some(data.join("\n"))
  }
}