/**
 * # cli/backend/oversight-remote — `RemoteOversight`, the SSE client for the oversight stream
 * (kestrel-telx.5; contract §4).
 *
 * The REMOTE half of the {@link ../../protocol/oversight.ts OversightBackend} seam — the CLI (Ink)
 * and the Web dashboard are the two clients of ONE model, so an event the CLI cannot name is not an
 * event the Web may quietly render. Its local twin is {@link ../../oversight/local.ts LocalOversight}.
 *
 * ## THE ROUTE (§4.1)
 * ```
 * GET /sessions/{sessionId}/oversight?cursor={opaque}   Accept: text/event-stream
 *                                                        Last-Event-ID: {same opaque}
 * ```
 * `sessionId` is the deterministic `SessionId` (genesis hash) — the identity a self-hosted kestrel
 * and the managed backend both derive.
 *
 * ## TRANSPORT — REUSED, never re-implemented
 * Framing, the opaque cursor, `?cursor=`+`Last-Event-ID` agreement, the bounded reconnect budget and
 * the "off-vocabulary `event:` ⇒ fail closed" gate are the shared leaf's
 * ({@link ./sse.ts} → {@link ../../client/sse-transport.ts}). This module supplies the oversight
 * VOCABULARY (`isOversightEventType`), the oversight ERROR taxonomy, and the five remaining
 * §4.3 rules; it owns no framing code. The leaf is push-based (`onFrame`) and the seam is pull-based
 * (`AsyncIterable`), so the only glue here is a bounded hand-off queue.
 *
 * ## THE SIX FAIL-CLOSED CLIENT RULES (§4.3) — each one loud, none silent
 *  1. Unknown `event:` name ⇒ `OVERSIGHT_PROTOCOL_VIOLATION`. Enforced by handing the leaf
 *     `isOversightEventType` + {@link oversightProtocolViolation}, so the gate sits on the REAL
 *     transport path (not a private re-check that a caller could bypass).
 *  2. Non-JSON `data:` on a typed event ⇒ violation. So is a body that does not match the wire
 *     shape — {@link ../../protocol/oversight-wire.ts} decodes field-by-field and throws.
 *  3. SNAPSHOT-FIRST. A delta before any `oversight.snapshot` ⇒ violation (a Rendering may not
 *     invent a baseline). A re-emitted snapshot RE-BASELINES; one that regresses below the last
 *     seen `seq` is a lying stream ⇒ violation.
 *  4. MONOTONE seq on every BUS-BACKED event (`busSeqOf`); `seq <= last` ⇒ violation.
 *  5. TERMINAL. `oversight.finalized` ends the stream cleanly; `oversight.failed` THROWS
 *     (`OVERSIGHT_FAILED`). A stream that simply STOPS without either reaches the leaf's reconnect
 *     budget and dies as a hard 504 — a truncated stream can never read as success.
 *  6. The cursor is OPAQUE. It is never parsed here — {@link OversightStreamReader} is not even
 *     given it, and no cursor is ever derived from a decoded body; the leaf hands the last `id:`
 *     back verbatim on BOTH `?cursor=` and `Last-Event-ID`.
 *
 * ## 402 IS DATA (ADR-0004)
 * {@link RemoteOversight.openStream} returns {@link Gated}: at the paid boundary the platform's
 * structured `OfferResponse` is surfaced as a VALUE, never as an exception and never as a browser
 * redirect (`human_action.url` is never followed). The plain {@link RemoteOversight.stream} is the
 * `OversightBackend` method and has no data channel for an Offer, so it delegates to `openStream`
 * and fails LOUDLY when gated — never an empty stream that reads as "no events".
 *
 * ## WHAT IS DELIBERATELY NOT HERE (fail closed, never a stub)
 * `submitAct` / `say` / `respond` have NO platform route: §4.1 adds exactly one route, and the
 * write-side authz is contract §10's named open question. They REFUSE loudly, exactly as
 * {@link ./remote.ts RemoteBackend.getPercept} refuses a route that does not exist. A stub that
 * returned `ok:true` would be a lie about coverage.
 */

import type { Gated } from "./index.ts";
import { CliError, EXIT } from "../errors.ts";
import { type FetchLike, consumeSseStream, httpErr } from "./sse.ts";
import {
  type InputResolution,
  type OversightAct,
  type OversightBackend,
  type OversightEvent,
  type OversightFrame,
  type OversightIdentity,
  isOversightEventType,
} from "../../protocol/oversight.ts";
import type { SessionId } from "../../protocol/session.ts";
import {
  OversightWireError,
  asOfferResponse,
  busSeqOf,
  decodeOversightEvent,
  type WireOfferResponse,
} from "./wire.ts";

/** Fail-closed error for anything the oversight contract forbids (§4.3). The mirror of
 *  {@link ./sse.ts unknownEvent}, with the contract's own code. */
export function oversightProtocolViolation(detail: string): CliError {
  return new CliError({
    code: "OVERSIGHT_PROTOCOL_VIOLATION",
    exit: EXIT.RUNTIME_UNAVAILABLE,
    message: `oversight protocol violation — ${detail}`,
  });
}

/** Rule 1's factory, in the shape the transport requires. */
function unknownOversightEvent(type: string): CliError {
  return oversightProtocolViolation(
    `unknown event ${JSON.stringify(type)} — not in the closed OversightEventType vocabulary`,
  );
}

/** The client's private cancel signal (a consumer abandoned the iterator). NOT an error — it is
 *  swallowed at the one place it can arise, and it is a class of its own so it can never be
 *  confused with a protocol violation. */
class StreamCancelled {}

/**
 * The RULE MACHINE — pure, stateful over ONE connection's event order: rules 2, 3, 4 and 5.
 *
 * Deliberately NOT given the cursor (rule 6: an opaque bookmark this layer has no business
 * reading). Rule 1 is not here either: it belongs on the transport's vocabulary gate, where every
 * frame must pass it.
 */
export class OversightStreamReader {
  private baselined = false;
  private lastSeq = -1;

  /** Decode one in-vocabulary frame, applying rules 2–5. Throws (fail closed) on any violation;
   *  returns `done` on the clean terminal. */
  accept(type: string, data: string): { readonly event: OversightEvent; readonly done: boolean } {
    if (!isOversightEventType(type)) throw unknownOversightEvent(type); // defensive; the gate is upstream

    // Rule 2 — non-JSON `data:` on a typed event is a violation.
    let body: unknown;
    try {
      body = JSON.parse(data);
    } catch {
      throw oversightProtocolViolation(`event ${type} carried non-JSON data: (${data.length} bytes)`);
    }

    let event: OversightEvent;
    try {
      event = decodeOversightEvent(type, body);
    } catch (e) {
      // A wire-shape mismatch is the same class of failure as non-JSON: the client cannot name what
      // it received, so it refuses rather than rendering a partially-decoded frame.
      if (e instanceof OversightWireError) throw oversightProtocolViolation(e.message);
      throw e;
    }

    // Rule 3 — snapshot-first / re-baseline.
    if (event.type === "oversight.snapshot") {
      this.baselined = true;
      // A re-baseline may only ADVANCE the seq watermark. The contract lets the server re-emit a
      // snapshot at any point, including a slightly stale one on a resume — that is legal and is not
      // refused here (refusing it would be an invented rule). What it may NEVER do is RELAX rule 4:
      // taking the max means a replayed delta still reds instead of sneaking in behind a stale
      // baseline.
      this.lastSeq = Math.max(this.lastSeq, event.frame.asofSeq);
      return { event, done: false };
    }
    if (!this.baselined) {
      throw oversightProtocolViolation(
        `${event.type} arrived before any oversight.snapshot — a Rendering may not invent a baseline`,
      );
    }

    // Rule 4 — monotone seq on the bus-backed members.
    const seq = busSeqOf(event);
    if (seq !== null) {
      if (seq <= this.lastSeq) {
        throw oversightProtocolViolation(
          `${event.type} regressed seq ${seq} <= last seen ${this.lastSeq} — a stream that regresses is lying`,
        );
      }
      this.lastSeq = seq;
    }

    // Rule 5 — terminals.
    if (event.type === "oversight.failed") {
      throw new CliError({
        code: "OVERSIGHT_FAILED",
        exit: EXIT.RUNTIME_UNAVAILABLE,
        message: `oversight stream failed: ${event.reason}`,
      });
    }
    return { event, done: event.type === "oversight.finalized" };
  }
}

/** A one-slot-at-a-time hand-off from the push transport to the pull `AsyncIterable`. */
class HandoffQueue<T> {
  private readonly buffer: T[] = [];
  private waiting: { resolve: (v: IteratorResult<T>) => void; reject: (e: unknown) => void } | undefined;
  private ended = false;
  private failure?: unknown;
  cancelled = false;

  push(v: T): void {
    if (this.waiting) {
      const w = this.waiting;
      this.waiting = undefined;
      w.resolve({ value: v, done: false });
    } else this.buffer.push(v);
  }
  close(): void {
    this.ended = true;
    if (this.waiting) {
      const w = this.waiting;
      this.waiting = undefined;
      w.resolve({ value: undefined, done: true });
    }
  }
  fail(e: unknown): void {
    this.failure = e;
    this.ended = true;
    if (this.waiting) {
      const w = this.waiting;
      this.waiting = undefined;
      w.reject(e);
    }
  }
  async *drain(): AsyncGenerator<T> {
    try {
      for (;;) {
        if (this.buffer.length > 0) {
          yield this.buffer.shift() as T;
          continue;
        }
        if (this.failure !== undefined) throw this.failure;
        if (this.ended) return;
        const next = await new Promise<IteratorResult<T>>((resolve, reject) => {
          this.waiting = { resolve, reject };
        });
        if (next.done) return;
        yield next.value;
      }
    } finally {
      // A consumer that walks away (`break`) stops the underlying connection at the next frame.
      this.cancelled = true;
    }
  }
}

export interface RemoteOversightOptions {
  readonly baseUrl: string;
  /** The deterministic `SessionId` (genesis hash) the route is keyed by. */
  readonly sessionId: SessionId;
  readonly fetch?: FetchLike;
  /** Authorization (and any other) headers for each request. Absent ⇒ unauthenticated. */
  readonly auth?: () => Promise<Record<string, string>>;
  /**
   * Parse-first resolution of the one input box (§3.8). A REQUIRED dependency, exactly as in
   * `LocalOversight`: wiring a stub here would be a surface whose stated coverage exceeds its
   * actual coverage.
   */
  readonly resolveInput: (text: string) => InputResolution;
  /** Reconnect budget override (tests); defaults to the leaf's `MAX_RECONNECTS`. */
  readonly maxReconnects?: number;
}

export class RemoteOversight implements OversightBackend {
  readonly kind = "remote" as const;
  private readonly base: string;
  private readonly sessionId: SessionId;
  private readonly fetch: FetchLike;
  private readonly auth?: () => Promise<Record<string, string>>;
  private readonly resolver: (text: string) => InputResolution;
  private readonly maxReconnects?: number;

  constructor(opts: RemoteOversightOptions) {
    this.base = opts.baseUrl.replace(/\/+$/, "");
    this.sessionId = opts.sessionId;
    const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined);
    if (f === undefined)
      throw new CliError({
        code: "RUNTIME_UNAVAILABLE",
        exit: EXIT.RUNTIME_UNAVAILABLE,
        message: "global fetch unavailable — need node ≥18 or bun for the remote oversight stream",
      });
    this.fetch = f;
    if (opts.auth !== undefined) this.auth = opts.auth;
    this.resolver = opts.resolveInput;
    if (opts.maxReconnects !== undefined) this.maxReconnects = opts.maxReconnects;
  }

  /** §4.1 — the one oversight route. */
  private endpoint(): string {
    return `${this.base}/sessions/${encodeURIComponent(this.sessionId)}/oversight`;
  }

  /**
   * Open the stream. THE GATED ENTRY POINT: a 402 at the paid boundary comes back as
   * `{gated:true, payment}` — DATA, never an exception, never a redirect.
   *
   * `after` is the OPAQUE cursor handed back verbatim; absent ⇒ `""` (a cold connection, which the
   * contract answers with a snapshot first).
   */
  async openStream(after?: string): Promise<Gated<AsyncIterable<OversightEvent>>> {
    const queue = new HandoffQueue<OversightEvent>();
    const reader = new OversightStreamReader();
    let payment: WireOfferResponse | null = null;
    let opened: (() => void) | undefined;
    const firstResponse = new Promise<void>((resolve) => {
      opened = resolve;
    });

    /** The gating wrapper: it sees the RESPONSE, which is the only place a 402 body exists. The
     *  transport itself stays a transport — it never learns what an Offer is. */
    const gatedFetch: FetchLike = async (url, init) => {
      const res = await this.fetch(url, init);
      if (res.status === 402) {
        const body = (await res.json().catch(() => null)) as unknown;
        payment = asOfferResponse(body);
        // An unreadable 402 body is NOT a gate we can surface as data — refuse loudly (do not
        // resolve `firstResponse`, so `openStream` rejects with this error rather than handing back
        // a stream that would quietly yield nothing).
        if (payment === null) throw httpErr(402, "oversight stream gated with an unreadable 402 body");
        opened?.();
        // Hand the transport a body-less 402 so it fails closed; `openStream` turns that into DATA.
        return new Response(null, { status: 402 });
      }
      opened?.();
      return res;
    };

    const run = consumeSseStream({
      fetch: gatedFetch,
      endpoint: this.endpoint(),
      cursor: after ?? "",
      headers: this.auth ? await this.auth() : {},
      // RULE 1 lives HERE — on the real transport's vocabulary gate.
      isEventType: isOversightEventType,
      unknownEventError: unknownOversightEvent,
      httpError: httpErr,
      ...(this.maxReconnects !== undefined ? { maxReconnects: this.maxReconnects } : {}),
      // The `cursor` argument is deliberately NOT forwarded to the reader (rule 6), and no cursor is
      // ever returned from a decoded body — the opaque `id:` is the only bookmark.
      onFrame: (type, data) => {
        if (queue.cancelled) throw new StreamCancelled();
        const { event, done } = reader.accept(type, data);
        queue.push(event);
        return { done };
      },
    });
    run.then(
      () => queue.close(),
      (e: unknown) => (e instanceof StreamCancelled ? queue.close() : queue.fail(e)),
    );

    // Resolve as soon as the FIRST response is known (gated or not), or if the run dies first.
    await Promise.race([firstResponse, run.then(() => undefined)]);
    if (payment !== null) return { gated: true, payment };
    return { gated: false, value: queue.drain() };
  }

  /**
   * The `OversightBackend` stream. Has no data channel for an Offer, so a gated session throws
   * LOUDLY (carrying the offer id) rather than yielding an empty stream that would read as
   * "nothing happened". Callers that can render an Offer use {@link openStream}.
   */
  stream(after?: string): AsyncIterable<OversightEvent> {
    const opened = this.openStream(after);
    return {
      [Symbol.asyncIterator]: async function* () {
        const g = await opened;
        if (g.gated) {
          throw new CliError({
            code: "REMOTE_GATED",
            exit: EXIT.GENERIC,
            message: `the oversight stream is gated behind offer ${g.payment.offer.offer_id} (${g.payment.offer.amount.value} ${g.payment.offer.amount.asset})`,
            hint: "settle the offer, or use openStream() to render it as data",
          });
        }
        yield* g.value;
      },
    };
  }

  /**
   * The current view. The contract guarantees SNAPSHOT-FIRST (§4.3 rule 3), so the frame IS the
   * stream's first event — there is no second route to fetch it from and none is invented. The
   * iterator is abandoned right after, which stops the connection at the next frame.
   */
  async frame(): Promise<OversightFrame> {
    for await (const e of this.stream()) {
      if (e.type === "oversight.snapshot") return e.frame;
      // Unreachable while rule 3 holds; if it ever is reached, the rule is broken — say so.
      throw oversightProtocolViolation(`expected oversight.snapshot first, got ${e.type}`);
    }
    throw oversightProtocolViolation("the oversight stream ended before delivering a snapshot");
  }

  async identity(): Promise<OversightIdentity> {
    return (await this.frame()).identity;
  }

  /** PURE and TOTAL (§3.8): a resolver that throws becomes a `parse-error`, never chat. */
  resolveInput(text: string): InputResolution {
    try {
      return this.resolver(text);
    } catch (e) {
      return { kind: "parse-error", diagnostics: [`input resolution failed: ${String(e)}`] };
    }
  }

  submitAct(_act: OversightAct): Promise<never> {
    return Promise.reject(this.noWriteRoute("submitAct"));
  }
  say(_text: string, _to: string | null): Promise<never> {
    return Promise.reject(this.noWriteRoute("say"));
  }
  respond(
    _r:
      | { readonly kind: "authored"; readonly document: string }
      | { readonly kind: "pass" }
      | { readonly kind: "stand-down"; readonly reason: string },
  ): Promise<never> {
    return Promise.reject(this.noWriteRoute("respond"));
  }

  /** The write side of the seam has NO platform route yet (contract §4.1 adds exactly one route;
   *  §10 names the authz question as open). Refuse loudly — never a fabricated route, never a
   *  silent `ok:true`. */
  private noWriteRoute(op: string): CliError {
    return new CliError({
      code: "REMOTE_UNSUPPORTED",
      exit: EXIT.USAGE,
      message: `${op} has no remote contract route — the oversight contract defines exactly one route today: GET /sessions/{id}/oversight`,
      hint: "run the session locally (LocalOversight) until the write routes land",
    });
  }
}
