/**
 * # cli/backend/remote — HTTP+SSE client vs kestrel.markets (ADR-0004/0010).
 *
 * Speaks the CANONICAL platform contract, byte-for-byte (kestrel-5rb):
 *   - `POST /capabilities/trial`  → mint an anonymous trial capability; the response
 *     `capability` is the `Authorization: Bearer` token.
 *   - `POST /validate`  {source}  → ValidateResult (pure; no Operation).
 *   - `POST /sim`  SimRequest{source, dataset:{artifact_id}}  → **201 Operation**
 *     (direct), or **402 OfferResponse** at the paid-dataset boundary.
 *   - `POST /grade`  GradeRequest{blotters:[id,…]}  → **201 Operation**.
 *   - `GET /operations/{id}/events`  → SSE, `event:` ∈ the `operation.*`
 *     OperationEvent.type enum, opaque-STRING `id:` cursor, resume by `?cursor=` and
 *     `Last-Event-ID` (the two must agree — we send the same value on both).
 *
 * Wire shapes (snake_case, {@link ./wire.ts Money}, opaque cursor) live in `wire.ts`
 * and are decoded to the OSS `src/protocol` domain types at the boundary. The
 * structured **402/Offer is surfaced as DATA** (never a browser redirect;
 * `human_action.url` is never fetched). Effectful POSTs carry a DETERMINISTIC
 * `Idempotency-Key` (a sha256 of the canonical request) so a replay is effect-once
 * (ADR-0009) without any wall-clock/RNG on the runtime path.
 *
 * Type-only protocol imports (erased) + a pure sha256 → no chdb/bun:sqlite; SSE
 * reads the web `ReadableStream` on `response.body` → node AND bun compatible.
 */

import type {
  TrialCapability,
  GradeResult,
  CertifiedGrade,
  Blotter,
} from "../../protocol/index.ts";
import type { EpisodeReport } from "../heavy.ts";
import { type KestrelNode } from "../../lang/index.ts";
import { canonicalPlansText } from "../../canonical/plans.ts";
import { CliError, EXIT } from "../errors.ts";
import {
  type WireValidateResult,
  type WireOperation,
  decodeOperation,
} from "./wire.ts";
import { type FetchLike, httpErr, unknownEvent } from "./sse.ts";
import {
  jsonHeaders,
  bearerHeader,
  mintTrialCapability,
  postEffectful,
  consumeOperationStream,
  parseValidateDiagnostics,
} from "../../client/platform-wire.ts";
import type {
  ExecutionBackend,
  Gated,
  PerceptArgs,
  PerceptFrame,
  PlanResult,
  SessionArgs,
  DayArgs,
  RemoteRunResult,
  GradeArgs,
  SessionScope,
} from "./index.ts";

/** The single canonical managed API base, owned by {@link ../../client/platform-wire.ts}
 *  and re-exported here so `import { DEFAULT_API } from "./remote.ts"` keeps resolving for
 *  the CLI's `select`/`sim`/`agent` callers (kestrel-z473.2). */
export { DEFAULT_API } from "../../client/platform-wire.ts";

export interface RemoteOptions {
  readonly baseUrl: string;
  readonly fetch?: FetchLike;
  readonly capability?: TrialCapability; // pre-seeded (tests); else minted lazily
  /**
   * A pre-resolved bearer token from a stored REGISTERED-agent credential
   * (`kestrel register`, kestrel-markets-0t0). When present, it is used as the
   * `Authorization: Bearer` on every call and NO anonymous trial is minted — the
   * registered durable capability supersedes the anon path (PLAT-ADR-0021 tier 1).
   */
  readonly bearer?: string;
  /**
   * A KEYPAIR-JWT signer (kestrel-markets-0t0 slice 2). When present it takes precedence
   * over `bearer`: EACH authorized request mints a FRESH short-lived JWT (unique jti) that
   * the platform verifies against the agent's enrolled key. This is the possession-proof
   * auth path — the durable capability is never sent. Absent ⇒ the bearer/anon path is used
   * unchanged (kcap stays fully working; JWT is additive).
   */
  readonly jwtSigner?: () => Promise<string>;
}

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

export class RemoteBackend implements ExecutionBackend {
  readonly kind = "remote" as const;
  private readonly base: string;
  private readonly fetch: FetchLike;
  private cap?: TrialCapability;
  private bearer?: string;
  private readonly jwtSigner?: () => Promise<string>;

  constructor(opts: RemoteOptions) {
    this.base = opts.baseUrl.replace(/\/+$/, "");
    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 --api",
      });
    this.fetch = f;
    if (opts.jwtSigner !== undefined) {
      // The keypair-JWT path (kestrel-markets-0t0 slice 2) supersedes the bearer: each request
      // mints a fresh short-lived JWT. No anon trial is minted and the durable kcap is not sent.
      this.jwtSigner = opts.jwtSigner;
    }
    if (opts.capability !== undefined) {
      this.cap = opts.capability;
      this.bearer = opts.capability.capabilityId;
    } else if (opts.bearer !== undefined) {
      // A stored REGISTERED credential (kestrel-markets-0t0) — present it directly; the
      // registered durable capability supersedes the anonymous trial mint.
      this.bearer = opts.bearer;
    }
  }

  /**
   * Ensure a bearer is set before an authorized call. If a REGISTERED credential (or a
   * pre-seeded capability) already supplied one, this is a no-op — no anon trial is
   * minted. Otherwise it lazily mints an anonymous trial (proof-before-account).
   */
  private async ensureBearer(): Promise<void> {
    if (this.bearer !== undefined) return;
    await this.bootstrapCapability();
  }

  // ── capability bootstrap: POST /capabilities/trial (mintTrialCapability) ──

  async bootstrapCapability(): Promise<TrialCapability> {
    if (this.cap) return this.cap;
    // TrialCapabilityRequest: an optional agent-supplied subject commitment; omit it and let
    // the platform mint one (ADR-0009). The mint (POST + Idempotency-Key + WireTrialCapability
    // decode) is the shared platform-wire helper; this face supplies its CliError factory and
    // owns the caching.
    const { capability, bearer } = await mintTrialCapability({
      fetch: this.fetch,
      base: this.base,
      onHttpError: remoteHttpErr,
    });
    this.bearer = bearer; // the bearer token (openapi: `capability`)
    this.cap = capability;
    return this.cap;
  }

  // ── the View/briefing frame has NO M1 contract route → FAIL CLOSED ──

  async getPercept(_args: PerceptArgs): Promise<Gated<PerceptFrame>> {
    // The M1 surface (docs/contract §"The M1 route surface") is exactly
    // capability-discovery / catalog / validate / sim / grade / operations /
    // offers-settlement / proof / events — there is NO percept/View/briefing route.
    // Fail closed rather than fabricate one (kestrel-5rb OWNER-NOTE: a remote View
    // projection is an owner decision — local-only face vs a new platform op).
    throw new CliError({
      code: "REMOTE_UNSUPPORTED",
      exit: EXIT.USAGE,
      message: "the frame/percept View has no remote contract route (M1: catalog, validate, sim, grade, operations, offers, proof, events)",
      hint: "run `frame` locally — omit --api; a remote View projection is not in the platform contract",
    });
  }

  // ── submitPlan → POST /validate (validateSource): pure, no Operation ──

  async submitPlan(documents: readonly KestrelNode[]): Promise<Gated<PlanResult>> {
    const auth = await this.authHeaders();
    const canonicalText = canonicalPlansText(documents);
    // ValidateRequest{source}. Validation is pure (no side effect) → NO Idempotency-Key.
    const res = await this.fetch(`${this.base}/validate`, {
      method: "POST",
      headers: { ...jsonHeaders(), ...auth },
      body: JSON.stringify({ source: canonicalText }),
    });
    if (res.status === 422) {
      // fail-closed parse/validate failure: surface diagnostics, ok:false (never a
      // 200 hiding a silent default).
      return {
        gated: false,
        value: { ok: false, canonicalText, documents, diagnostics: await parseValidateDiagnostics(res) },
      };
    }
    if (!res.ok) throw await remoteHttpErr(res, "POST /validate failed");
    const wire = (await res.json()) as WireValidateResult;
    return {
      gated: false,
      value: {
        ok: wire.valid === true,
        canonicalText,
        documents,
        diagnostics: (wire.diagnostics ?? []).map((d) => `${d.severity} ${d.code}: ${d.message}`),
      },
    };
  }

  // ── sessions → POST /sim (runSim): 201 Operation (direct) | 402 OfferResponse ──

  async openSession(_scope: SessionScope, args: SessionArgs): Promise<Gated<RemoteRunResult>> {
    return this.runSim(args, /*day*/ false);
  }
  async openDaySession(_scope: SessionScope, args: DayArgs): Promise<Gated<RemoteRunResult>> {
    return this.runSim(args, /*day*/ true);
  }

  private async runSim(args: SessionArgs & Partial<DayArgs>, _day: boolean): Promise<Gated<RemoteRunResult>> {
    const source = canonicalPlansText(args.documents);
    // SimRequest{source, dataset:{artifact_id}, params?}. The paid boundary is a
    // property of the DATASET (out-of-catalog → 402), NOT a client-sent scope — the
    // contract has no `scope` field on SimRequest.
    const artifactId = args.busPath;
    if (artifactId === undefined)
      throw new CliError({
        code: "USAGE",
        exit: EXIT.USAGE,
        message: "remote sim requires a dataset — pass a catalog artifact id (the bus/data reference)",
      });
    const req = {
      source,
      dataset: { artifact_id: artifactId },
      params: { fill_model: args.fillModel, r_usd: args.rUsd },
    };
    const opened = await this.effectfulPost("/sim", req);
    if (opened.gated) return opened; // 402 before any work → surface the Offer as data

    const { payload } = await this.consumeStream(opened.value);
    const report = payload["report"] as EpisodeReport | undefined;
    if (report === undefined) throw httpErr(502, "sim stream carried no report artifact");
    // A RemoteRunResult certifies a Blotter — fail closed if the stream omitted one (never
    // synthesize a silent default; the type asserts remote always carries the execution record).
    const blotter = payload["blotter"] as Blotter | undefined;
    if (blotter === undefined) throw httpErr(502, "sim stream carried no blotter artifact");
    const wakes = (payload["wakes"] ?? []) as RemoteRunResult["wakes"];
    return {
      gated: false,
      value: {
        report,
        plansText: source,
        wakes,
        operation: decodeOperation(opened.value),
        blotter,
      },
    };
  }

  // ── grade → POST /grade (runGrade): 201 Operation; result over SSE ──

  async grade(subject: Blotter | EpisodeReport, opts: GradeArgs): Promise<Gated<GradeResult | CertifiedGrade>> {
    // GradeRequest{blotters:[artifact_id,…]} — ids, NOT the whole Blotter. A Blotter's
    // `sessionId` is its artifact id; a EpisodeReport is not a persisted artifact and
    // cannot be graded remotely (fail closed).
    const blotters = [blotterId(subject), ...(opts.corpus ?? []).map((b) => b.sessionId)];
    const req = { blotters };
    const opened = await this.effectfulPost("/grade", req);
    if (opened.gated) return opened;

    const { payload } = await this.consumeStream(opened.value);
    const result = (payload["grade"] ?? payload["result"]) as GradeResult | CertifiedGrade | undefined;
    if (result === undefined) throw httpErr(502, "grade stream carried no grade artifact");
    return { gated: false, value: result };
  }

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

  private async effectfulPost(path: string, body: unknown): Promise<Gated<WireOperation>> {
    return postEffectful({
      fetch: this.fetch,
      base: this.base,
      path,
      body,
      authHeaders: await this.authHeaders(),
      httpError: httpErr,
      onHttpError: remoteHttpErr,
    });
  }

  // ── 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 (framing,
   *  opaque-cursor resume, fail-closed on an off-vocabulary event) is the shared platform-wire
   *  helper {@link consumeOperationStream}; this face supplies only its {@link CliError}
   *  factories. */
  private async consumeStream(op: WireOperation): Promise<{ payload: StreamPayload }> {
    return consumeOperationStream({
      fetch: this.fetch,
      base: this.base,
      operationId: op.operation_id,
      cursor: op.cursor,
      headers: await this.authHeaders(),
      httpError: httpErr,
      unknownEventError: unknownEvent,
    });
  }

  /**
   * The `Authorization` header for an authorized request. The keypair-JWT path wins when
   * configured — it mints a FRESH short-lived JWT per call (unique jti, so the platform's
   * replay guard never trips on a legitimate second request). Otherwise the static durable
   * bearer (or a lazily-minted anon trial) is used, exactly as before (kcap path intact).
   */
  private async authHeaders(): Promise<Record<string, string>> {
    if (this.jwtSigner !== undefined) {
      return { authorization: `Bearer ${await this.jwtSigner()}` };
    }
    await this.ensureBearer();
    return bearerHeader(this.bearer);
  }
}

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

/**
 * A `Response`→{@link CliError} factory that LIFTS the platform's domain `problem.code` to
 * the error's TOP-LEVEL `code`, so a wire client matching on `code` (Kestrel CLI v1 §6:
 * "agents match on `code`, not prose") reads the SAME stable DOMAIN code across transports —
 * `empty-grade` / `unknown-artifact` remotely EXACTLY as it reads them locally, never the
 * raw transport status `HTTP_422` / `HTTP_404` (kestrel-y1o0). The remote body already
 * CARRIES the domain code (`application/problem+json`, RFC 9457); before this, the top-level
 * field clients were told to match on was the HTTP status, so transport-agnostic error
 * handling was impossible without special-casing.
 *
 * The `exit` code still derives from the HTTP status (the transport tier is preserved — a
 * 404 stays NOT_FOUND, a 5xx stays RUNTIME_UNAVAILABLE), and the message keeps its
 * `(HTTP <status>)` marker. FAIL CLOSED: an absent / non-JSON / non-problem-shaped body (or
 * an absurdly long `code` a MITM'd error page might inject) degrades to the bare
 * `HTTP_<status>` code exactly as before — reading the body never throws inside error
 * handling (a throw here would mask the original failure).
 */
async function remoteHttpErr(res: Response, message: string): Promise<CliError> {
  const base = httpErr(res.status, message);
  const domainCode = await readProblemCode(res);
  if (domainCode === undefined) return base;
  return new CliError({
    code: domainCode,
    exit: base.exit,
    message: base.message,
    ...(base.hint !== undefined ? { hint: base.hint } : {}),
  });
}

/** The number of characters a lifted domain `code` may run to before we distrust it and
 *  fall back to the transport status. Real domain codes are short kebab-case slugs; a
 *  longer value is not a code we should ask an agent to match on. */
const MAX_DOMAIN_CODE = 128;

/**
 * The server's DOMAIN `code` off a non-2xx body, or `undefined` when there is nothing
 * problem-shaped to lift. Accepts `application/problem+json` AND any JSON object bearing a
 * non-empty string `code` (the platform's refusals are problem-shaped regardless of the
 * exact content-type). Fail-safe: any read/parse failure, or a `code` longer than
 * {@link MAX_DOMAIN_CODE}, returns `undefined` (the caller degrades to `HTTP_<status>`) — it
 * never throws inside error handling.
 */
async function readProblemCode(res: Response): Promise<string | undefined> {
  try {
    const body: unknown = await res.json();
    if (body === null || typeof body !== "object") return undefined;
    const code = (body as Record<string, unknown>)["code"];
    if (typeof code !== "string" || code.length === 0 || code.length > MAX_DOMAIN_CODE) return undefined;
    return code;
  } catch {
    return undefined; // absent / non-JSON / truncated body → degrade, never throw
  }
}

/** The Blotter's artifact id (its `sessionId`). A EpisodeReport is not a persisted
 *  artifact and cannot be graded remotely — fail closed. */
function blotterId(subject: Blotter | EpisodeReport): string {
  if (typeof (subject as Blotter).sessionId === "string") return (subject as Blotter).sessionId;
  throw new CliError({
    code: "USAGE",
    exit: EXIT.USAGE,
    message: "remote grade requires a Blotter artifact id — run the sim remotely first, then grade its Blotter",
  });
}

