/**
 * # cli/commands/agent — the machine/agent CLI mode as a LOSSLESS SDK PROJECTION (kestrel-djm.6)
 *
 * `kestrel agent` is the request/response stdio face an autonomous agent drives. It reads a JSONL REQUEST
 * stream on stdin (one verb envelope per line: `{ op, ...args }`) and writes a JSONL RESPONSE stream on
 * stdout (one versioned protocol object per line), forwarding every verb to the djm.5 SDK
 * ({@link createSdk} over `localTransport()` OR `remoteTransport({ baseUrl, fetch })`). It is a THIN
 * PROJECTION: it constructs the SAME typed client a programmatic caller constructs and NEVER reaches past
 * the SDK into SessionCore / the engine / the controller / the blotter / the bus. The verbs it drives are
 * exactly the {@link KestrelSdk}/{@link KestrelSession} surface — catalog / validate / openSession / start /
 * advance / revise / submit / resume / finalize / grade / artifact / resumeOperation.
 *
 * ── LOSSLESS + FAIL-CLOSED discipline (AGENTS.md non-negotiables) ──
 *  - stdout is ONLY versioned protocol JSONL: each line is `{ v: "kestrel.agent/v1", kind: <op>, value:
 *    <the djm.2 object the SDK verb returns, VERBATIM> }`. No face-local success/failure vocabulary crosses
 *    the payload channel — a 402/Offer is DATA (`openSession` emits `{ gated:true, payment }`), never a throw.
 *  - ALL human diagnostics go to stderr. A typed refusal (a malformed request, an unknown op, an unknown
 *    subject, an off-contract failure) is emitted to stderr carrying the SDK's OWN stable `code` (agents
 *    match on `code`, not prose), and the process fails closed with a nonzero exit — never a fabricated ok.
 *  - endpoint selection changes the TRANSPORT ONLY (identical to {@link resolveBackend}): no `--api` ⇒
 *    `localTransport()`; `--api <url>` / `KESTREL_API` ⇒ `remoteTransport({ baseUrl, fetch })`. Same request
 *    stream, byte-identical protocol objects across transports (the djm.5 parity headline, now on the CLI).
 *
 * ── DESIGN FORK LOGGED (hard rules forbid .beads writes — recorded here like djm.5's sdk/types.ts note) ──
 *  (1) AGENT MODE IS A SUBCOMMAND `kestrel agent` (a heavy verb, lazy-imported by the router like `run`/`day`).
 *  (2) TRANSPORT VIA THE EXISTING `--api` FLAG (peeled into `globals.api` by `extractGlobals`) — identical to
 *      `resolveBackend`; a bare `--api` / `--api default` uses the canonical {@link DEFAULT_API} base.
 *  (3) VERSIONED ENVELOPE `{ v, kind, value }`, `value` the raw SDK payload byte-for-byte; `openSession`
 *      emits the offer/session id as DATA (`{ gated:false, sessionId } | { gated:true, payment }`).
 *  (4) INJECTABLE IO SEAM {@link CliIo} on `main(argv, io?)` — `{ stdin, stdout, stderr, fetch }` (defaults:
 *      process streams + global fetch; `bin.ts` unchanged) so stdout purity is testable in-memory.
 */

import { DEFAULT_API } from "../backend/remote.ts";
import type { GlobalFlags } from "../context.ts";
import { EXIT } from "../errors.ts";
import {
  agentDescribe,
  agentOpNames,
  ARTIFACT_REF_SHAPE,
  ArgShapeError,
  asAuthoredResponse,
  asBlotters,
  asBoundResponse,
  asEventCursor,
  asRequiredString,
  asSessionRef,
  createSdk,
  localTransport,
  OPERATION_ID_SHAPE,
  remoteTransport,
  VALIDATE_DOCUMENT_SHAPE,
  type KestrelSdk,
  type KestrelSession,
  type OperationRef,
  type ProblemDetails,
} from "../../sdk/index.ts";

/** The versioned agent protocol tag every stdout line and every stderr refusal carries. */
export const AGENT_PROTOCOL_VERSION = "kestrel.agent/v1";

/**
 * The valid request ops, in handler order (the `describe` self-description op first, then `catalog`). DERIVED
 * from {@link AGENT_OP_SCHEMAS} (src/sdk/op-schema.ts) so the advertised set can NEVER fork from the described
 * set — the `describe` op emits those same schemas, `kestrel agent --help` renders them, and the MCP face
 * reads their shape strings. This is the SAME set the switch below dispatches; it is surfaced in the
 * `unknown-op` refusal so a caller that guesses wrong is TOLD the valid ops on its own (stderr) channel.
 * Kept in lockstep with the switch and with `kestrel agent --help` (src/cli/commands/meta.ts `SUBHELP.agent`).
 */
export const AGENT_OPS: readonly string[] = agentOpNames;

/** The web `fetch` shape the remote transport speaks (structurally the client's `FetchLike`). */
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;

/**
 * The optional, backward-compatible IO seam threaded through `main(argv, io?)`. Defaults bind the real
 * process streams + global fetch (so `bin.ts` is unchanged); a test drives it in-memory to assert stdout
 * purity without global monkeypatching.
 */
export interface CliIo {
  /** Read the entire JSONL request stream (defaults to draining `process.stdin`). */
  readonly stdin?: () => Promise<string>;
  /** Write a chunk to the protocol channel (defaults to `process.stdout`). */
  readonly stdout?: (chunk: string) => void;
  /** Write a chunk to the diagnostic channel (defaults to `process.stderr`). */
  readonly stderr?: (chunk: string) => void;
  /** Inject a `fetch` for the remote transport (defaults to `globalThis.fetch`). */
  readonly fetch?: FetchLike;
}

/** A fail-closed CLI-level refusal carrying a stable `code` (an agent matches on `code`, not prose). Used
 *  only for genuinely CLI-protocol conditions (a malformed request line, a session verb before openSession,
 *  an unknown op); DOMAIN refusals carry the SDK's OWN `code` (extracted by {@link codeOf}). */
class AgentRefusal extends Error {
  override readonly name = "AgentRefusal";
  readonly code: string;
  constructor(code: string, message: string) {
    super(message);
    this.code = code;
  }
}

/** Extract a thrown error's stable `code` — the SDK's own vocabulary (SdkLocalError / KestrelClientError /
 *  AgentRefusal all expose one), never a face-local string. Falls back to a generic tag. */
function codeOf(e: unknown): string {
  if (e !== null && typeof e === "object" && "code" in e) {
    const code = (e as { code?: unknown }).code;
    if (typeof code === "string" && code.length > 0) return code;
  }
  return "error";
}

/** Extract the server's problem+json diagnostics off a thrown error when present (a `KestrelClientError`
 *  enriched by the 109 client, kestrel-3w9r). `undefined` when the error carried no structured `problem` — the
 *  refusal then degrades to `code` + `message` only, exactly today's shape. Fail-safe: never throws. */
function problemOf(e: unknown): ProblemDetails | undefined {
  if (e !== null && typeof e === "object" && "problem" in e) {
    const p = (e as { problem?: unknown }).problem;
    if (p !== null && typeof p === "object") return p as ProblemDetails;
  }
  return undefined;
}

/** Build the ONE typed client over the chosen transport. Endpoint selection changes the TRANSPORT ONLY —
 *  no `--api` ⇒ LOCAL; `--api <url>` (bare / `default` ⇒ {@link DEFAULT_API}) ⇒ REMOTE. */
function buildSdk(api: string | undefined, fetchImpl: FetchLike): KestrelSdk {
  if (api === undefined) return createSdk(localTransport());
  const baseUrl = api === "" || api === "default" ? DEFAULT_API : api;
  return createSdk(remoteTransport({ baseUrl, fetch: fetchImpl }));
}

/**
 * Drain fd 0 to a string — the default request source when no IO seam is injected. Under Bun, read via
 * `Bun.stdin.text()` (fd 0 directly), NOT `for await (const chunk of process.stdin)`: once `process.stdin.isTTY`
 * is touched (it is, on the main path — `resolveFromProcess` reads it before dispatch), async-iterating a
 * FILE-redirected `process.stdin` under Bun drains ZERO bytes, which would silently swallow every request
 * (`kestrel agent < requests.jsonl` → 0 protocol objects, exit 0 — the fail-closed violation AGENTS.md
 * forbids). `Bun.stdin.text()` reads the redirected file faithfully. The async-iter path is retained as the
 * node fallback (the bundled `dist/cli.js` under bun-less node drains either stream kind correctly).
 */
async function drainProcessStdin(): Promise<string> {
  if (typeof Bun !== "undefined") return await Bun.stdin.text();
  const chunks: Buffer[] = [];
  for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
  return Buffer.concat(chunks).toString("utf8");
}

/** Require an active Session for a session verb; a fail-closed typed refusal otherwise (never a silent no-op). */
function requireSession(s: KestrelSession | undefined): KestrelSession {
  if (s === undefined) {
    throw new AgentRefusal("no-session", "no active Session — issue `openSession` before a session verb");
  }
  return s;
}

/**
 * Run the agent request/response loop. Reads the whole JSONL request stream, forwards each verb to the SDK,
 * and writes one versioned protocol object per request to stdout. Fails closed on the first typed refusal:
 * a stable-`code` diagnostic to stderr + a nonzero exit (the payload channel never carries a fabricated ok).
 */
export async function agentCommand(globals: GlobalFlags, io?: CliIo): Promise<number> {
  const readIn = io?.stdin ?? drainProcessStdin;
  const writeOut = io?.stdout ?? ((s: string) => void process.stdout.write(s));
  const writeErr = io?.stderr ?? ((s: string) => void process.stderr.write(s));
  const fetchImpl = io?.fetch ?? (globalThis.fetch as FetchLike);
  const sdk = buildSdk(globals.api, fetchImpl);

  /** Emit one versioned protocol object on the PAYLOAD channel — the SDK value carried verbatim. */
  const emit = (kind: string, value: unknown): void => {
    writeOut(JSON.stringify({ v: AGENT_PROTOCOL_VERSION, kind, value }) + "\n");
  };
  /**
   * Emit a typed refusal on the DIAGNOSTIC channel — carrying the stable `code`, never on stdout. When the
   * SDK/client preserved the server's `application/problem+json` diagnostics (kestrel-3w9r), they ride as an
   * ADDITIVE structured `problem` field on the SAME `kestrel.agent/v1` envelope (title/detail/code/remediation)
   * — the reason the wire carried, now on the author's channel as structured data (`message` already carries
   * title+detail in prose). Adding an optional field is version-compatible: a v1 consumer that ignores
   * `problem` reads exactly today's shape.
   */
  const refuse = (code: string, message: string, problem?: ProblemDetails): void => {
    writeErr(
      JSON.stringify({
        v: AGENT_PROTOCOL_VERSION,
        kind: "refusal",
        code,
        message,
        ...(problem !== undefined ? { problem } : {}),
      }) + "\n",
    );
  };

  const raw = await readIn();
  const requestLines = raw.split("\n").filter((l) => l.trim().length > 0);

  // FAIL CLOSED on an empty/absent request stream. Zero request lines off a non-interactive stdin (a pipe or
  // a redirected file — the agent scenario) is NOT success: it is either a genuinely empty stream or the
  // Bun-drain hazard above, and returning EXIT.OK would be exactly the silent no-op AGENTS.md forbids ('never
  // a silent default'). Emit a typed refusal (stable `code`, diagnostic channel) + a nonzero exit. The only
  // empty read that is NOT a refusal is an interactive TTY on the DEFAULT drain path (a human gave no input,
  // not a redirected stream) — never the injected-IO seam and never a pipe/file.
  if (requestLines.length === 0) {
    const interactiveTty = io?.stdin === undefined && Boolean(process.stdin.isTTY);
    if (!interactiveTty) {
      refuse(
        "empty-request-stream",
        "agent mode read zero request lines from stdin — expected one JSONL verb envelope (`{ op, ... }`) per line",
      );
      return EXIT.USAGE;
    }
  }

  let session: KestrelSession | undefined;

  for (const rawLine of requestLines) {
    let req: Record<string, unknown>;
    try {
      const parsed: unknown = JSON.parse(rawLine);
      if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
        throw new AgentRefusal("bad-request", "a request line must be a JSON object");
      }
      req = parsed as Record<string, unknown>;
    } catch (e) {
      const code = e instanceof AgentRefusal ? e.code : "bad-request";
      refuse(code, `unparseable request line: ${rawLine}`);
      return EXIT.USAGE;
    }

    const op = req["op"];
    try {
      switch (op) {
        case "describe":
          // SELF-DESCRIPTION (kestrel-yuw4): emit this protocol's own op/argument/response shapes — the ONE
          // op-schema catalogue (src/sdk/op-schema.ts), session-free, so a cold integrator learns every
          // `{ kind: … }` shape (and the exact accepted field names) from the wire instead of brute-forcing them.
          emit("describe", agentDescribe(AGENT_PROTOCOL_VERSION));
          break;
        case "catalog":
          emit("catalog", await sdk.catalog());
          break;
        case "validate":
          emit("validate", await sdk.validate(asRequiredString(req["document"], "document", VALIDATE_DOCUMENT_SHAPE)));
          break;
        case "openSession": {
          // An optional `document` supplies the customer strategy (Kestrel plan text) the managed
          // author-no-strategy fence requires (ADR-0012, kestrel-88u2) — the agent-face analogue of the sim
          // verb's `--plans`. Absent ⇒ the bare form: for a catalog subject the platform's fence answers, now
          // a LEGIBLE refusal (kestrel-3w9r). Present ⇒ threaded to POST /sim as the source.
          const document = req["document"] !== undefined ? String(req["document"]) : undefined;
          // VALIDATE the subject SHAPE (kestrel-yuw4): a malformed subject (a string, a kind-less object) is a
          // fail-closed refusal that NAMES the expected `{ kind: … }` vocabulary — never a blind cast that
          // dereferences undefined inside the SDK. A well-formed-but-unresolvable subject passes through to
          // the SDK's typed domain refusal.
          const gated = await sdk.openSession(asSessionRef(req["subject"]), document);
          if (gated.gated) {
            emit("openSession", { gated: true, payment: gated.payment });
          } else {
            session = gated.value;
            emit("openSession", { gated: false, sessionId: gated.value.sessionId });
          }
          break;
        }
        case "start":
          emit("start", await requireSession(session).start());
          break;
        case "advance":
          emit("advance", await requireSession(session).advance(asAuthoredResponse(req["response"])));
          break;
        case "revise":
          emit("revise", await requireSession(session).revise(asAuthoredResponse(req["response"])));
          break;
        case "submit":
          emit("submit", await requireSession(session).submit(asBoundResponse(req["response"])));
          break;
        case "resume":
          emit("resume", await requireSession(session).resume(asEventCursor(req["after"])));
          break;
        case "finalize":
          emit("finalize", await requireSession(session).finalize());
          break;
        case "grade":
          emit("grade", await sdk.grade({ blotters: asBlotters(req["blotters"]) }));
          break;
        case "artifact":
          emit("artifact", await sdk.artifact(asRequiredString(req["ref"], "ref", ARTIFACT_REF_SHAPE)));
          break;
        case "resumeOperation": {
          const after = asEventCursor(req["after"]);
          const ref: OperationRef = {
            operationId: asRequiredString(req["operationId"], "operationId", OPERATION_ID_SHAPE),
            ...(after !== undefined ? { after } : {}),
          };
          emit("resumeOperation", await sdk.resumeOperation(ref));
          break;
        }
        default:
          refuse(
            "unknown-op",
            `unknown agent op ${JSON.stringify(op)} — valid ops: ${AGENT_OPS.join(", ")} (see \`kestrel agent --help\`)`,
          );
          return EXIT.USAGE;
      }
    } catch (e) {
      // A malformed ARGUMENT OBJECT (kestrel-yuw4): a fail-closed USAGE refusal whose message NAMES the
      // expected `{ kind: … }` shape (code `bad-arg`), NOT a mid-stream domain failure — the caller supplied a
      // structurally wrong argument, exactly what `describe` documents how to avoid.
      if (e instanceof ArgShapeError) {
        refuse(e.code, e.message);
        return EXIT.USAGE;
      }
      // A DOMAIN refusal from the SDK (SdkLocalError / KestrelClientError) or a CLI-level AgentRefusal:
      // fail closed with the SDK's OWN stable code on the diagnostic channel, never a fabricated success.
      // A KestrelClientError may carry the server's problem+json diagnostics (kestrel-3w9r) — surface them.
      refuse(codeOf(e), e instanceof Error ? e.message : String(e), problemOf(e));
      return EXIT.GENERIC;
    }
  }

  return EXIT.OK;
}
