/**
 * # cli/errors — fail closed, loud, machine-parseable (Kestrel CLI v1 §6)
 *
 * One error type ({@link CliError}) carrying a stable `code`, an `exit` code, and an optional
 * `hint`. The router body ({@link ./index.ts}) is wrapped in a single try/catch; any throw is
 * rendered by {@link fail} to **stderr** in the active mode and its exit code returned. stdout
 * stays a pure payload channel, so an agent's `JSON.parse(stdout)` never sees a half-written
 * object followed by an error. `code` strings are stable across releases — agents match on
 * `code`, not prose.
 */

import type { OutputCtx } from "./context.ts";
// The server's `application/problem+json` diagnostics (RFC 9457), preserved through the throw so a
// backend that reads a typed refusal body (e.g. hosted.ts POST /simulate, kestrel-markets-dd1z) can
// surface the server's `title`/`detail`/`code`/`remediation` on the SAME frame — additively on `--json`,
// folded into the message on the human/text frames. Type-only import (erased at runtime; no coupling to
// the SDK client's module graph). Mirrors {@link ../client/index.ts KestrelClientError.problem}.
import type { ProblemDetails } from "../client/index.ts";

/** The exit-code map. `0` iff the command truly succeeded; every error path is nonzero. */
export const EXIT = {
  OK: 0,
  GENERIC: 1, // unexpected/uncaught
  USAGE: 2, // bad/unknown flag, missing required, bad --format, parse failure
  NOT_FOUND: 3, // `runs show <id>` / `lineage <name>` absent
  RUNTIME_UNAVAILABLE: 4, // bun/chdb needed but unloadable (loadHeavy)
  PAYMENT_REQUIRED: 5, // a remote verb returned a 402/Offer (surfaced as data)
  HANDSHAKE: 6, // `day`: the author handshake got no reply (codes NO_AUTHOR / HANDSHAKE_ABANDONED)
  // `paper`: a typed PaperSessionRefused (kestrel-7o2.12) — an unreachable/degraded IB Gateway, an
  // unresolved contract, an absent/stale tape, a tripped kill-switch, an unbounded ceiling, or a
  // non-paper mode. Its OWN exit, never GENERIC/1: exit 1 is "an unexpected/uncaught error", and a
  // caller must be able to tell the fail-closed path WORKING from a crash.
  PAPER_REFUSED: 7,
  SIGINT: 130,
} as const;

/** A domain error with a stable code, an exit code, and an optional hint line. When the error came off a
 *  non-2xx wire body carrying `application/problem+json` (or problem-shaped JSON), the parsed
 *  {@link ProblemDetails} ride on `problem` — the server's own diagnostics, preserved through the throw so
 *  {@link fail} can surface them (title/detail/code/remediation) instead of collapsing them to a bare
 *  transport line (kestrel-markets-dd1z). The `message` already carries title+detail in prose; `problem`
 *  is the ADDITIVE structured form agents read off the `--json` envelope. */
export class CliError extends Error {
  override readonly name = "CliError";
  readonly code: string;
  readonly exit: number;
  readonly hint?: string;
  readonly problem?: ProblemDetails;
  constructor(o: { code: string; exit: number; message: string; hint?: string; problem?: ProblemDetails }) {
    super(o.message);
    this.code = o.code;
    this.exit = o.exit;
    if (o.hint !== undefined) this.hint = o.hint;
    if (o.problem !== undefined) this.problem = o.problem;
  }
}

const RED = "[31m";
const DIM = "[2m";
const RESET = "[0m";

/**
 * The transport errnos a raw `fetch` throws when it never reached an HTTP status — DNS misses,
 * refused/reset/timed-out/unreachable sockets. Node/undici surface these as the system errno on
 * the `TypeError`'s `cause`; Bun uses its own string codes for the same faults. An HTTP *status*
 * (4xx/5xx) is NOT here — those are already typed by {@link ./backend/hosted.ts httpErr}.
 */
const CONNECTION_ERRNOS = new Set([
  // Node / undici (libuv errno)
  "ENOTFOUND",
  "EAI_AGAIN",
  "ECONNREFUSED",
  "ECONNRESET",
  "ECONNABORTED",
  "ETIMEDOUT",
  "EHOSTUNREACH",
  "ENETUNREACH",
  "ENETDOWN",
  "EPIPE",
  "EAI_FAIL",
  "UND_ERR_CONNECT_TIMEOUT",
  "UND_ERR_SOCKET",
  // Bun (SystemError string codes)
  "ConnectionRefused",
  "ConnectionClosed",
  "ConnectionTimeout",
  "FailedToOpenSocket",
  "DNSError",
]);

/** The `code` string off an error-shaped value, if it carries one. */
function errCode(e: unknown): string | undefined {
  if (typeof e === "object" && e !== null && "code" in e) {
    const c = (e as { code?: unknown }).code;
    if (typeof c === "string") return c;
  }
  return undefined;
}

/**
 * Type a raw connection-level `fetch` failure (network down / DNS fail / refused socket) as a
 * fail-closed {@link CliError} — code `NETWORK_UNAVAILABLE`, exit {@link EXIT.RUNTIME_UNAVAILABLE}
 * (4), with a hint pointing at the network + the API base — instead of letting the untyped
 * `TypeError` fall through {@link fail} as an unclear GENERIC exit 1 (PR #68 review 6a, kestrel-a3v9).
 *
 * Returns `null` for anything that is NOT a transport failure: an already-typed {@link CliError}
 * (an HTTP status, a USAGE/NOT_FOUND — leave it alone) and any other throw (a real bug stays
 * GENERIC, never masked as a network blip). The signal is the standard shape a `fetch` transport
 * fault takes in this runtime — a `TypeError` whose own or `cause`'s `code` is a known connection
 * errno, or undici's bare `TypeError: fetch failed` — never a broad "any TypeError" catch.
 */
export function asConnectionError(e: unknown, base?: string): CliError | null {
  if (e instanceof CliError) return null;
  const cause = typeof e === "object" && e !== null ? (e as { cause?: unknown }).cause : undefined;
  const code = errCode(e) ?? errCode(cause);
  const isFetchTransport =
    (code !== undefined && CONNECTION_ERRNOS.has(code)) ||
    (e instanceof TypeError && /fetch failed|failed to fetch|unable to connect|failed to connect/i.test(e.message));
  if (!isFetchTransport) return null;
  // Surface the transport root cause, not just the opaque `TypeError: fetch failed` wrapper: the
  // errno (ENOTFOUND / ECONNREFUSED / …) is what tells network-down from DNS-fail from refused.
  const outer = e instanceof Error ? e.message : String(e);
  const inner = cause instanceof Error ? cause.message : undefined;
  const parts = [outer];
  // Append the underlying cause message (which carries the errno) when it adds information the
  // wrapper does not already state — undici's outer message is a bare "fetch failed".
  if (inner !== undefined && inner !== outer) parts.push(inner);
  else if (code !== undefined && !outer.includes(code)) parts.push(code);
  const detail = parts.join(" — ");
  const where = base !== undefined && base !== "" ? ` reaching ${base}` : "";
  return new CliError({
    code: "NETWORK_UNAVAILABLE",
    exit: EXIT.RUNTIME_UNAVAILABLE,
    message: `network unavailable${where}: ${detail}`,
    hint: "check your network connection and the API base (--api / KESTREL_API)",
  });
}

/**
 * Render an error to **stderr** in the active mode and return its exit code. Never throws, never
 * writes to stdout. A non-`CliError` throw is treated as GENERIC (exit 1); `KESTREL_DEBUG=1` also
 * dumps its stack in that case (dev only).
 */
export function fail(ctx: OutputCtx, err: unknown): number {
  const isCli = err instanceof CliError;
  const code = isCli ? err.code : "GENERIC";
  const exit = isCli ? err.exit : EXIT.GENERIC;
  const message = err instanceof Error ? err.message : String(err);
  const hint = isCli ? err.hint : undefined;
  // The server's structured problem+json diagnostics, when a backend preserved them on the throw
  // (kestrel-markets-dd1z). The `message` already folds title+detail into prose for the human/text
  // frames; on `--json` they ride ADDITIVELY as `error.problem` so an agent reads the server's
  // `title`/`detail`/`code`/`remediation` as data, not just off the prose message.
  const problem = isCli ? err.problem : undefined;

  if (ctx.mode === "json") {
    const obj: { error: { code: string; message: string; hint?: string; problem?: ProblemDetails } } = {
      error: { code, message },
    };
    if (hint !== undefined) obj.error.hint = hint;
    if (problem !== undefined) obj.error.problem = problem;
    process.stderr.write(JSON.stringify(obj) + "\n");
  } else if (ctx.mode === "text") {
    let line = `error\tcode=${code}\tmessage=${message}`;
    if (hint !== undefined) line += `\thint=${hint}`;
    process.stderr.write(line + "\n");
  } else {
    // human
    const emsg = ctx.color ? `${RED}error:${RESET} ${message}` : `error: ${message}`;
    process.stderr.write(emsg + "\n");
    if (hint !== undefined) {
      const hmsg = ctx.color ? `${DIM}hint: ${hint}${RESET}` : `hint: ${hint}`;
      process.stderr.write(hmsg + "\n");
    }
  }

  if (!isCli && process.env.KESTREL_DEBUG === "1" && err instanceof Error && err.stack !== undefined) {
    process.stderr.write(err.stack + "\n");
  }
  return exit;
}
