/**
 * # kestrel.markets/client — the launch-min programmatic client (kestrel-109).
 *
 * The SDK face of the "four equal faces" (http / sdk / cli / mcp — ADR-0004). A
 * small, dependency-light client for the day-one programmatic flow:
 *
 *   mint a trial capability → run a sim → stream the operation → read the
 *   proof / receipt (the certified Blotter, and the judged Grade over it).
 *
 * It speaks the SAME canonical platform contract the CLI's `RemoteBackend`
 * (`src/cli/backend/remote.ts`, post-kestrel-5rb) speaks, byte-for-byte, because
 * it decodes through the ONE shared wire dialect in `src/cli/backend/wire.ts`
 * (snake_case bodies, {@link WireMoney} Money, opaque-STRING cursor, the
 * `operation.*` SSE vocabulary). Sharing `wire.ts` is what makes drift
 * impossible: there is exactly one definition of the shapes / SSE event names,
 * pinned to the platform OpenAPI fixture by the conformance suite — this client
 * cannot re-fabricate the old divergent dialect (the failure kestrel-5rb killed).
 *
 * ## Why this is a standalone module, not a wrapper over RemoteBackend
 *
 * `RemoteBackend` implements the CLI-internal `ExecutionBackend` seam, whose
 * argument/result types (SessionArgs/EpisodeReport/FillModelName/WakeRecord/…)
 * transitively type-import `src/cli/heavy.ts` → `src/session/*` → the engine /
 * ledger tree, which value-imports `bun:sqlite` and `Bun.*`. The published
 * library is emitted by `tsconfig.build.json` with `types: []` (zero built-ins),
 * so pulling ANY of that graph into the client's emit closure fails the build.
 * `remote.ts` itself also relies on web ambient types the library build only gets
 * via the `DOM` lib. So a thin wrapper — or even a bare re-export — of
 * `RemoteBackend` is NOT buildable as a public library entry point.
 *
 * This module therefore reuses the DIALECT (`wire.ts`) and the DOMAIN types
 * (`src/protocol`) — both zero-dependency — and mirrors `remote.ts`'s HTTP/SSE
 * plumbing (deterministic Idempotency-Key, opaque-cursor SSE resume, structured
 * 402/Offer surfaced as DATA, fail-closed on unknown SSE events). It imports
 * NOTHING from `src/cli`. The public surface is clean `src/protocol` types.
 *
 * CLEANUP FOR kestrel-djm.5 (the full dual-transport SDK, POST-LAUNCH):
 *  - DONE (kestrel-bsn8): the SSE framing + opaque-cursor resume loop is no longer
 *    mirrored here — this client and `remote.ts` share the ONE library-safe transport
 *    leaf `./sse-transport.ts`; the CLI's `../cli/backend/sse.ts` is a thin adapter over
 *    the same leaf.
 *  - DONE (kestrel-z473.2): the whole platform-wire DECODE surface — `DEFAULT_API`, the
 *    Idempotency-Key, the JSON/auth headers, the trial mint, the effectful 402-gated POST,
 *    the 402/Offer decode, and the OperationEvent SSE decode/accumulate loop — is now the
 *    ONE library-safe module {@link ./platform-wire.ts}. This face is a thin adapter that
 *    supplies its {@link KestrelClientError} factories and owns caching; it no longer
 *    hand-mirrors any of the dialect, so `remote.ts` can never drift from it.
 *  - `contractMockFetch` is re-exported from `src/cli/backend/mock.ts` (the only
 *    `src/cli` coupling, and it is library-safe); relocate the mock to a neutral
 *    home (e.g. `src/client/mock.ts`) so the client owns its own test double.
 *  - Decouple `remote.ts` from `heavy.ts` so the CLI backend can itself be built
 *    on top of this client instead of the reverse.
 *
 * Determinism: no wall clock, no RNG — the Idempotency-Key is a pure sha256 of
 * the canonical request, so a replay is effect-once (ADR-0009). node ≥18 + bun +
 * Workers: web `fetch`/`ReadableStream`/`TextDecoder` only, no native deps.
 */

import type {
  TrialCapability,
  Operation,
  Blotter,
  GradeResult,
  CertifiedGrade,
} from "../protocol/index.ts";
import {
  type WireValidateResult,
  type WireOperation,
  type WireOfferResponse,
  decodeOperation,
} from "../cli/backend/wire.ts";
import {
  DEFAULT_API,
  jsonHeaders,
  idemKey,
  bearerHeader,
  decodeOfferResponse,
  parseValidateDiagnostics,
  mintTrialCapability,
  postEffectful,
  consumeOperationStream,
  type OperationEventView,
  type Gated402,
} from "./platform-wire.ts";

/** The canonical managed API base — the ONE definition, owned by
 *  {@link ./platform-wire.ts platform-wire} and re-exported here so a bare/empty base
 *  resolves identically across every face (kestrel-z473.2). */
export { DEFAULT_API } from "./platform-wire.ts";
export type { OperationEventView } from "./platform-wire.ts";

/** The web `fetch` shape the client speaks. Inject {@link contractMockFetch} (or
 *  any conforming stand-in) for offline / CI use; defaults to `globalThis.fetch`. */
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;

export interface ClientOptions {
  /** API base URL; defaults to {@link DEFAULT_API}. Trailing slashes are trimmed. */
  readonly baseUrl?: string;
  /** Transport override (tests / CI / a custom agent). Defaults to global fetch. */
  readonly fetch?: FetchLike;
  /** Pre-seeded capability; otherwise one is minted lazily on first effectful call. */
  readonly capability?: TrialCapability;
  /**
   * A pre-resolved bearer token from a REGISTERED-agent credential (`kestrel register`,
   * PLAT-ADR-0021 tier 1). When present it authorizes every call and NO anonymous trial
   * is minted — the registered durable capability supersedes the anon path.
   */
  readonly bearer?: string;
}

/**
 * A control-plane result that may be gated behind a structured 402/Offer. The
 * gated payload is the platform's OfferResponse ({@link WireOfferResponse}) —
 * snake_case + {@link ../cli/backend/wire.ts WireMoney}) — surfaced as DATA
 * (ADR-0002/0004), never a browser redirect and never a thrown error. Its
 * `offer` prices the conversion; its `proof` is the free result already earned
 * under the trial. Switch on `.gated`. Mirrors the CLI backend's `Gated<T>`.
 */
export type Gated<T> =
  | { readonly gated: false; readonly value: T }
  | { readonly gated: true; readonly payment: WireOfferResponse };

/** A plan submitted to `POST /sim`. `source` is Kestrel plan text (the server
 *  parses it); `dataset` is a catalog artifact id (the bus/data reference). */
export interface SimInput {
  readonly source: string;
  readonly dataset: string;
  readonly params?: { readonly fillModel?: string; readonly rUsd?: number };
  /** Observe the operation's SSE events as they arrive (progress/artifact/…). */
  readonly onEvent?: (event: OperationEventView) => void;
}

/** The completed sim: the operation identity + its certified artifacts. */
export interface SimResult {
  /** Server-assigned control-plane identity of the run (decoded {@link Operation}). */
  readonly operation: Operation;
  /** The certified execution record — the WHAT HAPPENED receipt (present on success). */
  readonly blotter?: Blotter;
  /** The opaque domain report payload (not part of the wire protocol). */
  readonly report?: unknown;
}

/** A grade submitted to `POST /grade`: the Blotter artifact ids to judge (a
 *  Blotter's `sessionId` is its artifact id). */
export interface GradeInput {
  readonly blotters: readonly string[];
  /** Observe the operation's SSE events as they arrive. */
  readonly onEvent?: (event: OperationEventView) => void;
}

/** A resume of a durable Operation (`GET /operations/{id}/events`): the operation id + the
 *  opaque `after` cursor to replay strictly after (absent ⇒ from the beginning). No side
 *  effect — an existing Operation is replayed, never a new one minted. */
export interface ResumeOperationInput {
  readonly operationId: string;
  /** Replay strictly after this opaque cursor (absent ⇒ from the beginning). */
  readonly after?: string;
  /** Observe the operation's SSE events as they arrive. */
  readonly onEvent?: (event: OperationEventView) => void;
}

/** The judged verdict over the graded Blotter(s): the portable {@link GradeResult}
 *  or, when the platform seals it, the signed {@link CertifiedGrade} receipt. */
export type GradeOutcome = GradeResult | CertifiedGrade;

/** Result of the pure `POST /validate` (no Operation): `ok` + diagnostics. */
export interface ValidateOutcome {
  readonly ok: boolean;
  /** Validated artifact id, when the server persists the plan. */
  readonly artifactId?: string;
  readonly diagnostics: readonly string[];
}

/**
 * The server's diagnostic body carried through a {@link KestrelClientError} when a
 * non-2xx response ships `application/problem+json` (RFC 9457) — or any problem-shaped
 * JSON (a body carrying `title` / `detail` / `code`). The platform's refusals are
 * exemplary here (e.g. the author-no-strategy fence: `{ title: "A customer strategy is
 * required", code: "validation_failed", detail: "author-no-strategy: …ADR-0012" }`);
 * this is the structured reason the CLIENT drops today. Every field is optional — a
 * partial body carries what it can; an absent/unparseable body yields no `problem` at
 * all (the error degrades to its bare transport message, never throws). Agents read
 * these fields off the JSONL refusal envelope; a human reads `title`/`detail` in the
 * message.
 */
export interface ProblemDetails {
  /** The short, human-readable summary of the problem (RFC 9457 `title`). */
  readonly title?: string;
  /** The problem-specific explanation (RFC 9457 `detail`). */
  readonly detail?: string;
  /** The stable DOMAIN code the server assigned (e.g. `validation_failed`) — distinct
   *  from the error's transport `code` (`HTTP_422`). */
  readonly code?: string;
  /** Any remediation hint the server offered (`remediation` / `remedy` / `fix`). */
  readonly remediation?: string;
  /** The HTTP status the body accompanied. */
  readonly status?: number;
}

/**
 * A fail-closed client error with a stable `code` (agents match on `code`, not
 * prose) and the originating HTTP `status` when there is one. A structured 402 is
 * NOT an error — it is returned as {@link Gated} data; everything else off the
 * happy path (non-2xx, non-JSON, unknown SSE event, missing artifact) is a loud,
 * typed throw. Never silent, never a default. When the non-2xx body carried
 * `application/problem+json` (or problem-shaped JSON), the parsed {@link ProblemDetails}
 * ride on `problem` — the server's own diagnostics, preserved through the throw so the
 * agent face can surface them (kestrel-3w9r).
 */
export class KestrelClientError extends Error {
  override readonly name = "KestrelClientError";
  readonly code: string;
  readonly status?: number;
  readonly problem?: ProblemDetails;
  constructor(o: { code: string; message: string; status?: number; problem?: ProblemDetails }) {
    super(o.message);
    this.code = o.code;
    if (o.status !== undefined) this.status = o.status;
    if (o.problem !== undefined) this.problem = o.problem;
  }
}

/** The opaque domain payload accumulated from an Operation's artifact/receipt SSE
 *  events (the protocol pins only the envelope; the body rides in `data`). */
type StreamPayload = Record<string, unknown>;

/**
 * The launch-min kestrel.markets client. Construct once, then `mint`/`validate`/
 * `sim`/`grade`. The capability is minted lazily on the first effectful call and
 * cached; every effectful POST carries a deterministic Idempotency-Key.
 *
 * @example
 * ```ts
 * import { KestrelClient } from "kestrel.markets/client";
 * const client = new KestrelClient();                 // → api.kestrel.markets
 * await client.mint();                                // trial capability
 * const sim = await client.sim({ source, dataset });  // → Gated<SimResult>
 * if (!sim.gated) {
 *   const grade = await client.grade({ blotters: [sim.value.blotter!.sessionId] });
 * }
 * ```
 */
export class KestrelClient {
  private readonly base: string;
  private readonly fetchImpl: FetchLike;
  private cap?: TrialCapability;
  private bearer?: string;

  constructor(opts: ClientOptions = {}) {
    this.base = (opts.baseUrl ?? DEFAULT_API).replace(/\/+$/, "");
    const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined);
    if (f === undefined)
      throw new KestrelClientError({
        code: "NO_FETCH",
        message: "global fetch unavailable — need node ≥18, bun, or pass { fetch } explicitly",
      });
    this.fetchImpl = f;
    if (opts.capability !== undefined) {
      this.cap = opts.capability;
      this.bearer = opts.capability.capabilityId;
    } else if (opts.bearer !== undefined) {
      // A REGISTERED-agent credential (kestrel-markets-0t0) — present it directly; no anon
      // trial is minted. The registered durable capability supersedes the trial path.
      this.bearer = opts.bearer;
    }
  }

  /** Ensure a bearer is set before an authorized call. A pre-seeded capability or a
   *  REGISTERED bearer short-circuits; otherwise an anonymous trial is minted lazily. */
  private async ensureBearer(): Promise<void> {
    if (this.bearer !== undefined) return;
    await this.mint();
  }

  /** `POST /capabilities/trial` — mint (once, then cached) an anonymous trial
   *  capability; its bearer token authorizes every subsequent call. Effectful →
   *  carries a deterministic Idempotency-Key. */
  async mint(): Promise<TrialCapability> {
    if (this.cap) return this.cap;
    const { capability, bearer } = await mintTrialCapability({
      fetch: this.fetchImpl,
      base: this.base,
      onHttpError: httpErrFrom,
    });
    this.bearer = bearer; // the bearer token (openapi: `capability`)
    this.cap = capability;
    return this.cap;
  }

  /** `POST /validate` — the pure, deterministic parse/validate. No Operation, no
   *  side effect (no Idempotency-Key). Fail-closed: a 422 surfaces diagnostics
   *  with `ok: false`, never a 200 hiding a silent default. */
  async validate(source: string): Promise<ValidateOutcome> {
    await this.ensureBearer();
    const res = await this.fetchImpl(`${this.base}/validate`, {
      method: "POST",
      headers: { ...jsonHeaders(), ...this.authHeader() },
      body: JSON.stringify({ source }),
    });
    if (res.status === 422) return { ok: false, diagnostics: await parseValidateDiagnostics(res) };
    if (!res.ok) throw await httpErrFrom(res, "POST /validate failed");
    const wire = (await res.json()) as WireValidateResult;
    return {
      ok: wire.valid === true,
      ...(wire.artifact_id !== undefined ? { artifactId: wire.artifact_id } : {}),
      diagnostics: (wire.diagnostics ?? []).map((d) => `${d.severity} ${d.code}: ${d.message}`),
    };
  }

  /** `POST /sim` — create an Operation and run the deterministic Session; the
   *  report/Blotter arrive over the operation's SSE stream, which this consumes to
   *  a terminal event. Returns the {@link SimResult}, or — at the paid-dataset
   *  boundary — a {@link Gated} 402/Offer surfaced as DATA (before any work). */
  async sim(input: SimInput): Promise<Gated<SimResult>> {
    const req = {
      source: input.source,
      dataset: { artifact_id: input.dataset },
      params: { fill_model: input.params?.fillModel, r_usd: input.params?.rUsd },
    };
    const opened = await this.effectfulPost("/sim", req);
    if (opened.gated) return opened; // 402 before any work → the Offer (+ proof) as data
    const { payload } = await this.consumeStream(opened.value, input.onEvent);
    return {
      gated: false,
      value: {
        operation: decodeOperation(opened.value),
        ...(payload["blotter"] ? { blotter: payload["blotter"] as Blotter } : {}),
        ...(payload["report"] !== undefined ? { report: payload["report"] } : {}),
      },
    };
  }

  /** `POST /grade` — create an Operation grading one or more Blotter artifacts; the
   *  certified verdict arrives over the operation's SSE stream. May 402 (a grade
   *  scope beyond the trial). */
  async grade(input: GradeInput): Promise<Gated<GradeOutcome>> {
    const req = { blotters: input.blotters };
    const opened = await this.effectfulPost("/grade", req);
    if (opened.gated) return opened;
    const { payload } = await this.consumeStream(opened.value, input.onEvent);
    const result = (payload["grade"] ?? payload["result"]) as GradeOutcome | undefined;
    if (result === undefined) throw httpErr(502, "grade stream carried no grade artifact");
    return { gated: false, value: result };
  }

  /**
   * Resume a durable Operation's OUTCOME by REPLAYING its canonical event stream
   * (`GET /operations/{id}/events`) — the SAME resumable stream {@link sim}/{@link grade}
   * consume — to its terminal event, accumulating the domain payload from
   * `operation.artifact` / `operation.receipt` frames. This is how a COMPLETED operation's
   * outcome (blotter / report / receipt + artifact refs) is read back over the wire: it is
   * carried on the EVENT stream, NOT on the plain `GET /operations/{id}` status envelope
   * (which carries no domain payload for a completed op — the empty-payload defect a
   * wire-first resume hit, kestrel-o3bi). `after` is the opaque resume cursor (absent ⇒ from
   * the beginning); an off-vocabulary frame / `operation.failed` / a bad status is a loud,
   * fail-closed throw, never a silent empty payload. No new side effect (a pure GET, no
   * Idempotency-Key), so no Operation is minted — an existing one is replayed.
   */
  async resumeOperation(input: ResumeOperationInput): Promise<{ payload: StreamPayload }> {
    await this.ensureBearer();
    return consumeOperationStream({
      fetch: this.fetchImpl,
      base: this.base,
      operationId: input.operationId,
      cursor: input.after ?? "", // opaque STRING (never `.token` of a JSON object); "" ⇒ from the beginning
      headers: this.authHeader(),
      httpError: httpErr, // fail closed as KestrelClientError, not CliError
      unknownEventError: unknownEvent,
      ...(input.onEvent !== undefined ? { onEvent: input.onEvent } : {}),
    });
  }

  // ── HTTP core: effectful POST (Idempotency-Key) with structured-402 gating ──

  private async effectfulPost(path: string, body: unknown): Promise<Gated402<WireOperation>> {
    await this.ensureBearer();
    return postEffectful({
      fetch: this.fetchImpl,
      base: this.base,
      path,
      body,
      authHeaders: this.authHeader(),
      httpError: httpErr, // fail closed as KestrelClientError, not CliError
      onHttpError: httpErrFrom,
    });
  }

  // ── SSE: GET /operations/{id}/events, opaque-string cursor, operation.* events ──

  /** Read the Operation's canonical event stream to a terminal event, accumulating the
   *  domain payload from artifact/receipt events. The whole decode/accumulate loop is the
   *  ONE shared transport helper ({@link consumeOperationStream} in
   *  {@link ./platform-wire.ts}); this face supplies only its {@link KestrelClientError}
   *  factories and the optional observer. Resumable + fail-closed on an off-vocabulary
   *  event (both properties belong to the shared helper). */
  private consumeStream(
    op: WireOperation,
    onEvent?: (event: OperationEventView) => void,
  ): Promise<{ payload: StreamPayload }> {
    return consumeOperationStream({
      fetch: this.fetchImpl,
      base: this.base,
      operationId: op.operation_id,
      cursor: op.cursor,
      headers: this.authHeader(),
      httpError: httpErr, // fail closed as KestrelClientError, not CliError
      unknownEventError: unknownEvent,
      ...(onEvent !== undefined ? { onEvent } : {}),
    });
  }

  private authHeader(): Record<string, string> {
    return bearerHeader(this.bearer);
  }
}

/* ---------- helpers (module-scope; node + bun + workers) ---------- */

function httpErr(status: number, m: string): KestrelClientError {
  return new KestrelClientError({ code: `HTTP_${status}`, message: `${m} (HTTP ${status})`, status });
}

/**
 * Build a fail-closed error from a non-2xx {@link Response}, ENRICHED with any
 * `application/problem+json` (or problem-shaped JSON) diagnostics the server sent — the
 * reason the wire already carried (kestrel-3w9r). The reason is folded into the message
 * (`… (HTTP 422): <title> — <detail>`) AND preserved as structured {@link ProblemDetails}
 * on the error. FAIL-SAFE: this consumes the body inside a try; an absent, non-JSON, or
 * non-problem-shaped body degrades to the bare {@link httpErr} message and NEVER throws
 * inside error handling (an error path that throws would mask the original failure).
 */
async function httpErrFrom(res: Response, m: string): Promise<KestrelClientError> {
  const problem = await readProblem(res);
  if (problem === undefined) return httpErr(res.status, m);
  const parts = [problem.title, problem.detail].filter((s): s is string => s !== undefined);
  const suffix = parts.length > 0 ? `: ${parts.join(" — ")}` : "";
  return new KestrelClientError({
    code: `HTTP_${res.status}`,
    message: `${m} (HTTP ${res.status})${suffix}`,
    status: res.status,
    problem,
  });
}

/**
 * Parse a non-2xx body into {@link ProblemDetails}, or `undefined` when there is nothing
 * problem-shaped to carry. Accepts `application/problem+json` AND any JSON object bearing
 * a `title` / `detail` / `code` (or a remediation) — the server's diagnostics regardless
 * of the exact content-type. Fail-safe: any read/parse failure returns `undefined` (the
 * caller degrades to the bare message); it never throws.
 */
async function readProblem(res: Response): Promise<ProblemDetails | undefined> {
  try {
    const body: unknown = await res.json();
    if (body === null || typeof body !== "object") return undefined;
    const b = body as Record<string, unknown>;
    // Bound each passed-through diagnostic string: a server (or a MITM'd error page) must not be able to
    // balloon the refusal envelope / error message with an unbounded body. 2 KiB per field comfortably holds
    // every real platform diagnostic; anything longer is truncated with a marker (never dropped silently).
    const MAX_FIELD = 2048;
    const str = (v: unknown): string | undefined => {
      if (typeof v !== "string" || v.length === 0) return undefined;
      return v.length > MAX_FIELD ? `${v.slice(0, MAX_FIELD)}… [truncated]` : v;
    };
    const title = str(b["title"]);
    const detail = str(b["detail"]);
    const code = str(b["code"]);
    const remediation = str(b["remediation"]) ?? str(b["remedy"]) ?? str(b["fix"]);
    // A body is "problem-shaped" only if it carries at least one diagnostic field — otherwise
    // there is nothing to surface and we degrade to the bare transport message.
    if (title === undefined && detail === undefined && code === undefined && remediation === undefined) {
      return undefined;
    }
    return {
      ...(title !== undefined ? { title } : {}),
      ...(detail !== undefined ? { detail } : {}),
      ...(code !== undefined ? { code } : {}),
      ...(remediation !== undefined ? { remediation } : {}),
      status: res.status,
    };
  } catch {
    return undefined; // absent / non-JSON / truncated body → degrade, never throw
  }
}

/** Fail-closed error for an SSE event whose `type` is outside the OperationEvent
 *  enum (a protocol drift). Loud, never silently ignored (AGENTS.md non-negotiable). */
function unknownEvent(type: string): KestrelClientError {
  return new KestrelClientError({
    code: "SSE_PROTOCOL_VIOLATION",
    message: `unknown SSE event type ${JSON.stringify(type)} — not in the OperationEvent contract vocabulary`,
  });
}

/* ---------- re-exports (DX) ---------- */

/**
 * An in-process implementation of the kestrel.markets contract as a `fetch`
 * stand-in — for local development, the examples, and CI. Pass it as
 * `new KestrelClient({ fetch: contractMockFetch })` to exercise the full
 * mint→sim→grade flow with NO network and NO secrets. It serves canned artifacts
 * over the exact shared wire dialect (`wire.ts`); it is NOT a real backend.
 * (The only `src/cli` coupling — relocated to a neutral home in kestrel-djm.5.)
 */
export { mockFetch as contractMockFetch } from "../cli/backend/mock.ts";

/** The domain types the client returns — re-exported so consumers need only one
 *  import. The full protocol is `kestrel.markets/protocol`. */
export type { TrialCapability, Operation, Blotter, GradeResult, CertifiedGrade } from "../protocol/index.ts";

/** The structured 402/Offer wire shapes (the exact contract bodies surfaced as
 *  DATA on a {@link Gated} result). The full dialect is in `wire.ts`. */
export type {
  WireOfferResponse,
  WireOffer,
  WireProof,
  WireMoney,
  WireSettlementMethod,
  SseEventType,
} from "../cli/backend/wire.ts";
