/**
 * Parse a `text/event-stream` byte stream into typed frames.
 *
 * The BFF's chat relay (per [ADR-0024](../../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md))
 * writes one frame per upstream `ChatEvent`:
 *
 *     event: token
 *     data: {"token":"…","value":"…"}
 *
 *     event: done
 *     data: {"stats":{…}}
 *
 * The native browser `EventSource` API would parse this for free —
 * but `EventSource` is GET-only and the chat endpoint is POST (the
 * SPA carries the conversation history in the body). The parser
 * below works against the `fetch().body` `ReadableStream<Uint8Array>`
 * that POST + streaming gives us.
 *
 * The implementation buffers the incoming bytes, splits on the SSE
 * frame separator (a blank line — `\n\n`), and yields one parsed
 * frame per separator. Partial trailing data stays in the buffer
 * until the next chunk completes a frame.
 */

export interface SseFrame {
  /** SSE `event:` field, kebab-case per the bridge's contract. */
  readonly event: string;
  /** Pre-parsed `data:` payload — see {@link parseJsonOrPassthrough}. */
  readonly data: unknown;
}

/**
 * Consume a `ReadableStream<Uint8Array>` (`fetch().body`) and emit
 * one {@link SseFrame} per upstream event. The async iterable closes
 * when the underlying stream ends or aborts.
 *
 * Frames without a parseable `data:` payload (rare — malformed
 * server) are skipped silently. The terminal `event: done` frame is
 * yielded like any other; callers decide when to stop iterating.
 */
export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncIterable<SseFrame> {
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        // Flush any final frame that lacked a trailing blank line —
        // some proxies trim the very last `\n\n`. `parseFrame`
        // returns null on empty/partial input so the safety net is
        // free.
        const trailing = parseFrame(buffer);
        if (trailing !== null) {
          yield trailing;
        }
        return;
      }

      buffer += decoder.decode(value, { stream: true });

      // SSE frames are terminated by a blank line. Use `\n\n` (LF)
      // and tolerate `\r\n\r\n` (CRLF) — fetch's underlying stream
      // can produce either depending on the server / proxy chain.
      let separatorIndex = nextSeparator(buffer);
      while (separatorIndex !== -1) {
        const rawFrame = buffer.slice(0, separatorIndex.start);
        buffer = buffer.slice(separatorIndex.end);

        const frame = parseFrame(rawFrame);
        if (frame !== null) {
          yield frame;
        }

        separatorIndex = nextSeparator(buffer);
      }
    }
  } finally {
    // Releasing the lock lets the caller cancel the underlying
    // stream after iteration ends — and matches the documented
    // contract of `ReadableStreamDefaultReader`.
    reader.releaseLock();
  }
}

/**
 * Parse a single raw SSE frame (the bytes between two blank lines).
 *
 * Recognised fields:
 *   - `event: <name>` (defaults to `'message'` per the SSE spec when
 *     absent — the bridge always sends one, but the default keeps
 *     the parser forgiving).
 *   - `data: <text>` — the bridge serialises JSON, so the parser
 *     attempts `JSON.parse` and falls back to the raw text when the
 *     payload is not valid JSON.
 *
 * Lines starting with `:` are comments per the SSE spec; ignored
 * here. Unrecognised fields (`id:`, `retry:`) are ignored too —
 * the bridge does not emit them.
 */
function parseFrame(raw: string): SseFrame | null {
  if (raw.trim() === '') {
    return null;
  }
  let event = 'message';
  const dataLines: string[] = [];

  for (const line of raw.split(/\r?\n/)) {
    if (line === '' || line.startsWith(':')) {
      continue;
    }
    const colon = line.indexOf(':');
    if (colon === -1) {
      continue;
    }
    const field = line.slice(0, colon);
    // SSE spec: a single space after the colon is part of the field
    // syntax, not the value. Trim it.
    const value = line.slice(colon + 1).replace(/^ /, '');

    if (field === 'event') {
      event = value;
    } else if (field === 'data') {
      dataLines.push(value);
    }
  }

  if (dataLines.length === 0) {
    // Frame with only an `event:` line. The bridge always pairs
    // `event:` with `data:`, so this only triggers on malformed
    // input; safer to skip than to surface an undefined payload.
    return null;
  }

  return { event, data: parseJsonOrPassthrough(dataLines.join('\n')) };
}

/**
 * Try `JSON.parse`; on failure return the raw string so the caller
 * can still inspect malformed payloads (logging, dev diagnostics)
 * rather than fail the whole stream.
 */
function parseJsonOrPassthrough(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}

/**
 * Locate the next SSE separator (`\n\n` or `\r\n\r\n`) in the
 * buffer. Returns the slice indices so the caller can split the
 * buffer without scanning twice.
 */
function nextSeparator(buffer: string): { start: number; end: number } | -1 {
  const lf = buffer.indexOf('\n\n');
  const crlf = buffer.indexOf('\r\n\r\n');
  if (lf === -1 && crlf === -1) {
    return -1;
  }
  if (crlf !== -1 && (lf === -1 || crlf < lf)) {
    return { start: crlf, end: crlf + 4 };
  }
  return { start: lf, end: lf + 2 };
}
