/**
 * # client/sse-transport — the LIBRARY-SAFE SSE transport leaf (kestrel-bsn8).
 *
 * ONE SSE framing + opaque-cursor resume loop for every streamed Operation, shared by the
 * library-safe {@link ./index.ts KestrelClient} AND the CLI's {@link ../cli/backend/sse.ts}
 * (→ RemoteBackend, the oversight client): extract and reuse, never re-implement. The leaf knows
 * the WIRE (framing, the opaque cursor, resume, the reconnect budget) and NOTHING about any
 * caller's event vocabulary — the closed enum arrives as the caller's `isEventType` guard, and the
 * decoded body stays the caller's business.
 *
 *   - Framing: `\n\n`-delimited frames; `event:` name, concatenated `data:`, and the raw `id:`
 *     line as an OPAQUE STRING (never JSON-parsed). Comment lines (leading `:`) are SSE
 *     heartbeats and are ignored.
 *   - Resume: `?cursor=` and `Last-Event-ID` carry the SAME opaque value; a dropped connection
 *     re-opens from the LAST cursor — never from the beginning.
 *   - Fail closed: an `event:` outside the caller's vocabulary is a protocol violation, and a
 *     handler throw is a hard error — both are re-thrown past the reconnect loop (a drift is not a
 *     drop). A transient stream error reconnects, bounded by {@link MAX_RECONNECTS}; exhausting the
 *     budget without a terminal event is a hard 504, never a silent empty result.
 *
 * LIBRARY-SAFE: this module imports NOTHING from `src/cli`, holds no error type of its own, and
 * uses only web-standard globals (`fetch`/`ReadableStream`/`TextDecoder`/`URLSearchParams`) — so it
 * stays inside the `types:[]` light-build closure (tsconfig.build.json) that `./client` publishes.
 * It is ERROR-AGNOSTIC: the caller INJECTS the fail-closed error factories (`httpError` /
 * `unknownEventError`), so the SDK throws `KestrelClientError` and the CLI throws `CliError` over
 * this ONE loop, and this leaf depends on neither. Hard vs. transient is discriminated
 * STRUCTURALLY (a protocol violation / a handler throw is fail-closed; only a stream drop
 * reconnects), never by the error's class. node ≥18 + bun + Workers.
 */

/** Bounded SSE reconnect budget (network resilience; a dropped connection re-opens
 *  from the last opaque cursor — never restarts at the beginning). */
export const MAX_RECONNECTS = 5;

export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;

/** What the caller's frame handler tells the loop: whether the stream reached the caller's
 *  terminal event, and an optional cursor override carried inside the decoded body. */
export interface SseFrameOutcome {
  readonly done?: boolean;
  readonly cursor?: string;
}

export interface SseStreamOptions {
  readonly fetch: FetchLike;
  /** The events endpoint WITHOUT a cursor query — the loop owns `?cursor=`/`Last-Event-ID`. */
  readonly endpoint: string;
  /** The initial opaque cursor, handed back verbatim (never `.token` of a JSON object). */
  readonly cursor: string;
  /** Caller headers (e.g. `Authorization`); the SSE `accept` + resume headers are added here. */
  readonly headers?: Record<string, string>;
  /** The caller's CLOSED event vocabulary. Anything else → {@link SseStreamOptions.unknownEventError}. */
  readonly isEventType: (type: string) => boolean;
  /** Decode/accumulate one in-vocabulary frame. `cursor` is the opaque resume bookmark AT this
   *  frame (post `id:` advance); throw (any error) to fail closed. */
  readonly onFrame: (type: string, data: string, cursor: string) => SseFrameOutcome;
  readonly maxReconnects?: number;
  /** Build the fail-closed transport error for a bad HTTP status (non-ok stream, no body, budget
   *  exhaustion). REQUIRED — the leaf holds no error type of its own, so the caller supplies its
   *  own (e.g. `CliError` for the CLI, `KestrelClientError` for the SDK). */
  readonly httpError: (status: number, message: string) => Error;
  /** Build the fail-closed error for an off-vocabulary `event:`. REQUIRED. */
  readonly unknownEventError: (type: string) => Error;
}

/** A hard, fail-closed error the reconnect loop must re-throw (a protocol violation or a handler
 *  throw), wrapping the CALLER's original error. Distinguishing hard from transient STRUCTURALLY —
 *  not by the error's class — is what lets the transport stay error-agnostic (kestrel-bsn8). A bare
 *  stream drop is never wrapped, so it reconnects; anything wrapped here re-throws its `cause`. */
class FailClosed {
  constructor(readonly cause: unknown) {}
}

/** Read an event stream to the caller's terminal frame, resuming across drops from the last
 *  opaque cursor. Returns once `onFrame` reports `done`; otherwise fails closed. */
export async function consumeSseStream(opts: SseStreamOptions): Promise<void> {
  let cursor = opts.cursor;
  const budget = opts.maxReconnects ?? MAX_RECONNECTS;
  for (let attempt = 0; attempt < budget; attempt++) {
    const q = new URLSearchParams({ cursor });
    const res = await opts.fetch(`${opts.endpoint}?${q.toString()}`, {
      // `?cursor=` and `Last-Event-ID` must agree — we send the same value on both.
      headers: { ...opts.headers, accept: "text/event-stream", "last-event-id": cursor },
    });
    if (!res.ok) throw opts.httpError(res.status, "event stream failed");
    const stream = res.body;
    if (!stream) throw opts.httpError(500, "event stream had no body");
    try {
      for await (const f of parseSSE(stream)) {
        if (f.id !== undefined) cursor = f.id; // advance the opaque resume bookmark
        if (f.data === "" && f.event === undefined) continue; // heartbeat/comment-only frame
        const type = f.event ?? "";
        if (!opts.isEventType(type)) throw new FailClosed(opts.unknownEventError(type));
        let out: SseFrameOutcome;
        try {
          out = opts.onFrame(type, f.data, cursor);
        } catch (e) {
          throw new FailClosed(e); // a handler throw is a hard error, never a reconnect
        }
        if (out.cursor) cursor = out.cursor;
        if (out.done) return;
      }
    } catch (e) {
      if (e instanceof FailClosed) throw e.cause; // protocol violation / handler throw → fail closed
      continue; // transient stream drop → reconnect from `cursor`
    }
    // clean end without a terminal event → reconnect from the last cursor
  }
  throw opts.httpError(504, "event stream did not reach a terminal state after retries");
}

/** Minimal SSE frame parser over a web ReadableStream<Uint8Array> (node + bun + workers). Yields
 *  one record per `\n\n`-delimited frame: the `event:` name, the concatenated `data:` payload, and
 *  the raw `id:` line as an OPAQUE STRING (never JSON-parsed). Comment lines (leading `:`) are
 *  ignored (SSE heartbeats). */
export async function* parseSSE(
  body: ReadableStream<Uint8Array>,
): AsyncGenerator<{ event: string | undefined; data: string; id: string | undefined }> {
  const reader = body.getReader();
  const dec = new TextDecoder();
  let buf = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    let idx: number;
    while ((idx = buf.indexOf("\n\n")) >= 0) {
      const frame = buf.slice(0, idx);
      buf = buf.slice(idx + 2);
      let event: string | undefined;
      let data = "";
      let id: string | undefined;
      for (const line of frame.split("\n")) {
        if (line.startsWith(":")) continue; // comment / heartbeat
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).replace(/^ /, "");
        else if (line.startsWith("id:")) id = line.slice(3).trim(); // OPAQUE string cursor
      }
      yield { event, data, id };
    }
  }
}
