/**
 * Shared wire-protocol primitives used by every provider codec in
 * `src/protocol/`. This is the single home for the generic guards, UTF-16
 * well-formedness coercion, JSON/argument helpers, hash helpers, endpoint
 * URL construction, image-source resolution, and custom-header validation
 * that the per-wire request/response codecs build on — previously duplicated
 * across the Claude/Codex protocol modules and `providers/compatible-adapter`.
 *
 * Provider-specific primitives that depend on provider identity (e.g. Codex
 * identity headers) deliberately live with their provider, not here.
 */
import { providerBaseUrl } from "../providers/provider-metadata";
import { GatewayError } from "./gateway-error";
import type { CanonicalStopReason, CanonicalTerminalEvent, UsageRecord } from "./canonical-model";
import { type WireFamily } from "./canonical-model";
import type { GenerationControls } from "./canonical-model";
import { BASE_PROTECTED_HEADERS, HEADER_CONTROL, HEADER_TOKEN } from "../security/outbound-headers";
import { unwrapProviderToken } from "../providers/provider-credential-envelope";

const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/**
 * Deepest nesting the recursive rewriters below will descend into.
 *
 * Those helpers walk values that came from a request — tool schemas, tool
 * arguments, thinking payloads — and a plain recursion over request-supplied
 * data overflows the call stack at roughly 10,000 levels (measured: a 400 KB
 * body whose JSON nests 20,000 deep parses fine and then throws
 * `RangeError: Maximum call stack size exceeded`). The ingress guard's
 * `MAX_JSON_DEPTH = 64` does not cover this: it measures the envelope, and a
 * value carried as a JSON **string** inside that envelope (`function.arguments`
 * on the Chat wire, a schema in a string field) is parsed by a decoder *after*
 * the guard has passed.
 *
 * 256 is far above any real schema — a hand-written tool schema nests a
 * handful of levels — and far below the ~10,000 frames that overflow V8. A
 * value past the bound is forwarded **unchanged**, not dropped: these helpers
 * exist to make a value safe to forward, and discarding a caller's schema
 * would be worse than forwarding a shape the upstream may reject on its own.
 */
export const MAX_REWRITE_DEPTH = 256;

/**
 * Whether a walk is already inside `value` — i.e. the input contains a cycle.
 *
 * A depth bound cannot stop a cycle: a two-node loop overflows at any bound.
 * The check is per-walk rather than global, so the same node may legitimately
 * appear twice in a tree (a shared subschema) without being mistaken for one.
 */
function createsCycle(path: readonly object[], value: object): boolean {
  return path.includes(value);
}

/**
 * The recursion state every rewriter below threads through its walk.
 *
 * Kept in a private worker rather than as optional parameters on the exported
 * function. `Array.prototype.map` passes the element *index* as the second
 * argument, so an exported `(value, depth = 0)` would let a bare
 * `.map(toWellFormedDeep)` hand the index to `depth` — and past the bound the
 * guard would forward the value **unrewritten** instead of failing loudly. A
 * one-parameter export makes that call a type error.
 */
export interface RewriteWalk {
  readonly depth: number;
  readonly path: readonly object[];
}

/** The walk one level down, descending into `value`. */
export function descend(walk: RewriteWalk, value: object): RewriteWalk {
  return { depth: walk.depth + 1, path: [...walk.path, value] };
}

/** The walk state every rewrite starts from. */
export const ROOT_WALK: RewriteWalk = { depth: 0, path: [] };

/** True when this node must be forwarded as-is rather than rewritten. */
export function shouldStop(walk: RewriteWalk, value: object): boolean {
  return walk.depth >= MAX_REWRITE_DEPTH || createsCycle(walk.path, value);
}

/**
 * Shared 32-bit string hash (`hash * 31` accumulation, unsigned).
 */
export function hash32(input: string): number {
  let hash = 0;
  for (let i = 0; i < input.length; i++) {
    hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
  }
  return hash;
}

export const CLAUDE_TOOL_PREFIX = "_";

export type ClaudeWireObject = Record<string, unknown>;

/**
 * Object (non-array) narrowing for untyped wire JSON.
 *
 * The single record guard for the codebase. It lives here, in the lowest layer
 * that needs it, because `transport/surface` already imports from this module:
 * having protocol import a guard from surface created a cycle across layers.
 */
export function isRecord(value: unknown): value is ClaudeWireObject {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

export const stringValue = (value: unknown): value is string =>
  typeof value === "string";
export const finiteNumber = (value: unknown): value is number =>
  typeof value === "number" && Number.isFinite(value);

/** Wire guards: generic record/field readers used by protocol codecs. */
export function readString(value: Record<string, unknown>, key: string): string | undefined {
  const candidate = value[key];
  return typeof candidate === "string" ? candidate : undefined;
}

export function readNumber(value: Record<string, unknown>, key: string): number | undefined {
  const candidate = value[key];
  return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined;
}

/**
 * Reads an SSE frame's `output_index` as an integer slot.
 *
 * The Responses/Codex wires address output items by a fractional-capable
 * JSON number. Callers key maps and route deltas on this value, so a
 * non-integer index must be truncated rather than rejected: a fractional
 * index still identifies the same slot. Non-finite values (`NaN`,
 * `Infinity`) are discarded so they never become a map key, and a missing or
 * non-numeric field yields `undefined` rather than `0` so an absent index
 * cannot collide with a real slot zero.
 */
export function readOutputIndex(record: Record<string, unknown>): number | undefined {
  const candidate = record["output_index"];
  return typeof candidate === "number" && Number.isFinite(candidate)
    ? Math.trunc(candidate)
    : undefined;
}

export function readBoolean(value: Record<string, unknown>, key: string): boolean | undefined {
  const candidate = value[key];
  return typeof candidate === "boolean" ? candidate : undefined;
}

// Tool-call ID normalization

const ANTHROPIC_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;

function hashId(input: string): string {
  return hash32(input).toString(16).padStart(8, "0");
}

function isValidAnthropicToolCallId(id: string): boolean {
  return ANTHROPIC_ID_RE.test(id);
}

/**
 * Clamps a tool call id to a wire's length limit while keeping it recognisable.
 *
 * OpenCode Zen rejects a Responses `call_id` longer than 64 characters:
 * `` `input[5].call_id` The length of the value must be `<= 64` ``. The ids
 * that trip it come from the client (signature-style ids run to 156 chars), so
 * forwarding one verbatim fails a request the caller had no way to know was
 * oversized — and fails it on every replay, because the transcript keeps the
 * long id.
 *
 * Unlike {@link normalizeAnthropicToolCallId} this does not rewrite the id into
 * another provider's shape: only the length is a wire constraint here, so the
 * caller's alphabet is preserved. Truncation alone would collide — the ids
 * share a long common prefix — so the tail is replaced with a hash of the
 * original, which keeps distinct ids distinct.
 *
 * Returns the input unchanged when it already fits.
 */
export function clampToolCallId(raw: string, limit = 64): string {
  if (raw.length <= limit) return raw;
  const hash = hashId(raw);
  const suffix = `_${hash}`;
  if (suffix.length >= limit) return raw.slice(0, limit);
  return `${raw.slice(0, limit - suffix.length)}${suffix}`;
}

function fallbackAnthropicToolCallId(original: string): string {
  return `toolu_${hashId(original)}`;
}

/** Appends `_dupN`, carving the suffix out of the 64-char budget. */
function withDuplicateSuffix(canonical: string, index: number): string {
  const suffix = `_dup${index}`;
  return `${canonical.slice(0, 64 - suffix.length)}${suffix}`;
}

/**
 * Normalizes a tool-call ID for Anthropic's strict 64-char `^[a-zA-Z0-9_-]+$` requirement.
 * - Handles OpenAI Responses composite `callId|itemId` by taking the first segment.
 * - Replaces invalid chars with `_`, truncates to 64, and falls back to `toolu_<hash>` if empty.
 *
 * `seen` is the per-request uniqueness ledger: one map for a whole request, passed
 * in so every id in that request is distinct. It counts how many times each id has
 * been emitted (or reserved as a base), and this function is its only writer.
 *
 * The ledger is keyed on the CANONICAL form — the value after replacement,
 * truncation, and the hash fallback — not on the raw id. Keying on the raw was the
 * bug: two raws that canonicalize to the same base (`call!x` and `call@x` both
 * become `call_x`) have to share one counter, or the second one re-derives a
 * suffix the first already handed out. Both ends of the ledger are therefore
 * reserved: the base, and any generated `_dupN` result, so a later id that
 * naturally equals a generated one does not collide with it.
 */
export function normalizeAnthropicToolCallId(
  raw: string,
  seen: Map<string, number> = new Map(),
): string {
  // Handle composite IDs from Responses API: `call_id|item_id`
  const base = raw.includes("|") ? (raw.split("|")[0] ?? raw) : raw;
  let canonical = base.replace(/[^a-zA-Z0-9_-]/g, "_");
  if (canonical.length === 0) canonical = fallbackAnthropicToolCallId(raw);
  if (canonical.length > 64) {
    // Keep prefix + hash suffix to avoid collisions
    const hash = hashId(raw);
    canonical = `${canonical.slice(0, 55)}_${hash.slice(0, 8)}`.slice(0, 64);
  }
  if (!isValidAnthropicToolCallId(canonical)) {
    canonical = fallbackAnthropicToolCallId(raw);
  }
  // Walk forward from this base's next free index until an id nobody holds is
  // found. The first candidate is the canonical form itself, which is free
  // exactly when the base has never been emitted.
  let index = seen.get(canonical) ?? 0;
  let normalized = index === 0 ? canonical : withDuplicateSuffix(canonical, index);
  while (seen.has(normalized)) {
    index += 1;
    normalized = withDuplicateSuffix(canonical, index);
  }
  // Reserve both ends: the base so the next call starts past this index, and the
  // generated id so a later raw that canonicalizes to it is forced to a new one.
  seen.set(canonical, index + 1);
  if (normalized !== canonical) seen.set(normalized, (seen.get(normalized) ?? 0) + 1);
  return normalized;
}

// Anthropic tool-schema sanitization

const SUPPORTED_SCHEMA_KEYS: Readonly<Record<string, true>> = Object.freeze({
  $ref: true,
  $defs: true,
  definitions: true,
  type: true,
  enum: true,
  const: true,
  description: true,
  title: true,
  default: true,
  properties: true,
  required: true,
  additionalProperties: true,
  items: true,
  anyOf: true,
  oneOf: true,
  allOf: true,
  minItems: true,
  maxItems: true,
  minLength: true,
  maxLength: true,
  pattern: true,
  minimum: true,
  maximum: true,
  exclusiveMinimum: true,
  exclusiveMaximum: true,
  minProperties: true,
  maxProperties: true,
});

export function sanitizeSchemaForAnthropic(value: unknown): unknown {
  return sanitizeSchemaWalk(value, ROOT_WALK);
}

function sanitizeSchemaWalk(value: unknown, walk: RewriteWalk): unknown {
  if (Array.isArray(value)) {
    if (shouldStop(walk, value)) return value;
    const inner = descend(walk, value);
    return value.map((entry) => sanitizeSchemaWalk(entry, inner));
  }
  if (!isRecord(value)) return value;
  if (shouldStop(walk, value)) return value;
  const inner = descend(walk, value);
  const out: Record<string, unknown> = {};
  for (const [key, child] of Object.entries(value)) {
    // Property names are user-defined schema keys, not JSON-Schema keywords;
    // preserve them while sanitizing each property's schema recursively.
    if (key === "properties" && isRecord(child)) {
      out.properties = Object.fromEntries(
        Object.entries(child).map(([name, schema]) => [name, sanitizeSchemaWalk(schema, inner)]),
      );
      continue;
    }
    // Drop wire-unsupported keywords so a strict upstream never 400s on
    // them — but never invent caller text to say so. The dropped names are
    // debug-logged by the caller-facing builder when that matters.
    if (!SUPPORTED_SCHEMA_KEYS[key]) continue;
    out[key] = sanitizeSchemaWalk(child, inner);
  }
  const properties = out.properties;
  if (isRecord(properties)) {
    out.type ??= "object";
    // A-Carte's Anthropic-compatible endpoint validates tool schemas as
    // strict objects: every declared property must also appear in required.
    // Rebuild from the sanitized property names so dropped/unknown entries
    // cannot leave an invalid required list behind.
    out.required = Object.keys(properties);
  }
  return out;
}

// OAuth tool-name prefixing

const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set([
  "web_search",
  "web_fetch",
  "code_execution",
  "code_execution_20250825",
  "code_execution_20260120",
  "code_execution_20260521",
  "text_editor",
  "computer",
  "tool_search_tool_regex_20251119",
  "tool_search_tool_bm25_20251119",
  "tool_search_tool_regex",
  "tool_search_tool_bm25",
  "mcp_toolset",
]);

export function prefixClaudeToolName(name: string, isOAuth: boolean): string {
  if (!isOAuth || ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase())) return name;
  return `${CLAUDE_TOOL_PREFIX}${name}`;
}

export function unprefixClaudeToolName(name: string, isOAuth: boolean): string {
  if (!isOAuth || !name.startsWith(CLAUDE_TOOL_PREFIX)) return name;
  return name.slice(CLAUDE_TOOL_PREFIX.length);
}

/** Prefix marking an echoed billing attestation block in replayed history. */
export const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";

/**
 * Whether a system text block is an echoed billing attestation from a prior
 * turn. Such blocks must never be forwarded upstream again: they attest a
 * different body, and replaying them is precisely what abuse detection keys
 * on. Builders drop them when projecting system content.
 */
export function isClaudeBillingHeaderText(text: string): boolean {
  return text.startsWith(CLAUDE_BILLING_HEADER_PREFIX);
}

// Well-formed UTF-16 coercion

export function toWellFormedString(value: string): string {
  const maybe = value as unknown as { toWellFormed?: () => string };
  if (typeof maybe.toWellFormed === "function") return maybe.toWellFormed();
  // Fallback for runtimes without String.prototype.toWellFormed (replace lone surrogates)
  return value.replace(
    /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
    "\uFFFD",
  );
}

export function toWellFormedDeep(value: unknown): unknown {
  return toWellFormedWalk(value, ROOT_WALK);
}

function toWellFormedWalk(value: unknown, walk: RewriteWalk): unknown {
  if (typeof value === "string") return toWellFormedString(value);
  if (Array.isArray(value)) {
    if (shouldStop(walk, value)) return value;
    const inner = descend(walk, value);
    let changed = false;
    const next = value.map((entry) => {
      const sanitized = toWellFormedWalk(entry, inner);
      if (sanitized !== entry) changed = true;
      return sanitized;
    });
    return changed ? next : value;
  }
  if (value !== null && typeof value === "object") {
    if (shouldStop(walk, value)) return value;
    const inner = descend(walk, value);
    let changed = false;
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
      const sanitized = toWellFormedWalk(v, inner);
      if (sanitized !== v) changed = true;
      out[k] = sanitized;
    }
    return changed ? out : value;
  }
  return value;
}

// Argument-string JSON coercion

/**
 * Deepest nesting accepted for a JSON document carried by a request.
 *
 * One bound for both transports of the same content. A body may carry a
 * structure as nested JSON **objects** or as a JSON **string** in a field
 * (`function.arguments` on the Chat wire), and a limit applied to only one of
 * them is not a limit: the string form sidesteps it entirely. Measured before
 * this existed, an arguments string nesting 20,000 deep returned HTTP 200
 * through the real encoder and then threw `RangeError` inside the request
 * builder, and one at 50,000 threw from `JSON.stringify` on the outbound body.
 *
 * The ingress middleware (`body-policy.ts`) imports this constant for the
 * envelope check, so the two enforcement points cannot drift apart.
 */
export const MAX_JSON_DEPTH = 64;

/**
 * Rejects a value nested deeper than {@link MAX_JSON_DEPTH}.
 *
 * Fails fast — it throws *before* recursing past the bound — so it spends at
 * most 65 frames and cannot overflow the stack it is protecting. `JSON.parse`
 * never returns a cyclic graph, so a cycle cannot reach here.
 */
export function assertBoundedJsonDepth(value: unknown): void {
  assertBoundedDepthWalk(value, 0);
}

function assertBoundedDepthWalk(value: unknown, depth: number): void {
  if (depth > MAX_JSON_DEPTH)
    throw new GatewayError("invalid_request", 400, `The request body is nested deeper than this endpoint accepts (${MAX_JSON_DEPTH} levels). Flatten the payload.`);
  if (Array.isArray(value)) {
    for (const item of value) assertBoundedDepthWalk(item, depth + 1);
    return;
  }
  if (value !== null && typeof value === "object") {
    // for...in avoids allocating the Object.values() array per node.
    for (const key in value as Record<string, unknown>) {
      assertBoundedDepthWalk((value as Record<string, unknown>)[key], depth + 1);
    }
  }
}

export function parseArguments(value: unknown): unknown {
  // A zero-arg tool call carries "" (or absent) arguments. Callers on the
  // Messages wire need an object (`input`), so an empty string coerces to {}
  // rather than passing "" through and drawing an upstream 400.
  if (typeof value === "string") {
    if (value.trim().length === 0) return {};
    try {
      const parsed = JSON.parse(value) as unknown;
      // The envelope guard measured only the string, so this is the first
      // place the nesting inside it becomes visible. Reject it here, exactly
      // as the envelope guard would have rejected the same content sent as
      // nested objects — the alternative is forwarding a value the outbound
      // serializer cannot handle.
      assertBoundedJsonDepth(parsed);
      return parsed;
    } catch (error) {
      if (error instanceof GatewayError) throw error;
      return value;
    }
  }
  return value ?? {};
}

// Endpoint URL construction

export function endpointUrl(
  baseUrl: string | undefined,
  endpointPath: string,
  isOAuth = false,
): string {
  if (/^https?:\/\//i.test(endpointPath)) {
    if (isOAuth && !endpointPath.includes("?beta=true")) {
      const separator = endpointPath.includes("?") ? "&" : "?";
      return `${endpointPath}${separator}beta=true`;
    }
    return endpointPath;
  }
  const url = joinUrl((baseUrl ?? providerBaseUrl("claude")).replace(/\/+$/, ""), endpointPath);
  if (isOAuth) return `${url}?beta=true`;
  return url;
}

/** Session-scoped state (turn-state / models-etag / session identity). */
export interface CodexSessionState {
  sessionId: string;
  threadId: string;
  windowId: string;
  turnState?: string;
  modelsEtag?: string;
  turnStartedAtUnixMs?: number;
}

/**
 * ASCII-safe JSON: escapes every non-ASCII code unit as `\uXXXX`. The
 * `x-codex-turn-metadata` header must stay ASCII-clean and = ~100 KB;
 * mirrors provider `toAsciiJsonString` (openai-codex-responses.ts:623-628).
 */
export function toAsciiJsonString(value: Record<string, unknown>): string {
  return JSON.stringify(value).replace(
    /[\x7f-\uffff]/g,
    (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
  );
}

export function hashToBase36(input: string): string {
  return hash32(input).toString(36);
}

/**
 * Encodes a tool-call ID as a Responses-safe composite `callId__itemId`.
 * Codex rejects punctuation such as `|` in `id`/`call_id`, so the separator
 * must stay within the upstream `[A-Za-z0-9_-]` contract.
 */
export function encodeCodexToolCallId(
  callId: string,
  itemId?: string | null,
): string {
  const stableItemId =
    itemId !== undefined && itemId !== null && itemId.length > 0
      ? itemId
      : `fc_${hashToBase36(callId)}`;
  return `${callId}__${stableItemId}`;
}

/** Decodes current `__` composites and accepts legacy `|` values on replay. */
export function decodeCodexToolCallId(raw: string): string {
  const separator = raw.includes("__") ? "__" : "|";
  const idx = raw.indexOf(separator);
  return idx === -1 ? raw : raw.slice(0, idx);
}

const CODEX_WIRE_EFFORT_VALUES = [
  "none",
  "minimal",
  "low",
  "medium",
  "high",
  "xhigh",
  "max",
] as const;
export type CodexWireEffort = (typeof CODEX_WIRE_EFFORT_VALUES)[number];

/**
 * Maps canonical `ReasoningIntent.effort` to the wire tier strings the
 * ChatGPT backend accepts. Identity for the exact canonical vocabulary; an
 * unknown value falls back to `"medium"` rather than dropping the request.
 */
export function mapReasoningEffortToWireTier(
  effort: string | undefined,
): CodexWireEffort | undefined {
  if (effort === undefined) return undefined;
  if ((CODEX_WIRE_EFFORT_VALUES as readonly string[]).includes(effort))
    return effort as CodexWireEffort;
  return "medium";
}

/**
 * Resolves the requested output verbosity from a canonical request's
 * generation controls, independent of the surface it arrived on.
 *
 * Each surface files the same caller intent under a different key: Chat and
 * Completion carry the documented top-level `verbosity` as
 * `extension:verbosity`, Responses nests it under `text.verbosity` and stores
 * it as `extension:responses.verbosity`, and `GenerationControls.verbosity` is
 * the canonical slot. Reading only the canonical slot silently drops the
 * control on every real request, because no surface parser writes it — the
 * caller's `verbosity: "high"` reached the upstream as the model's default.
 *
 * The precedence mirrors `resolvePromptCacheKey`: the surface-specific
 * spelling wins over the bare one, and both win over the canonical slot.
 */
export function resolveOutputVerbosity(
  controls: GenerationControls | undefined,
): string | undefined {
  if (controls === undefined) return undefined;
  const candidates = [
    controls["extension:responses.verbosity"],
    controls["extension:verbosity"],
    controls.verbosity,
  ];
  for (const value of candidates) {
    if (typeof value === "string" && value.length > 0) return value;
  }
  return undefined;
}

/**
 * Reserved Harmony dialect control-token spellings. Escaping these to the
 * inert backslash form lets untrusted data (user text, tool results,
 * replayed history) reach Harmony-dialect models (gpt-5.x family) without
 * the backend's prompt validator rejecting the whole request as
 * `invalid_prompt` / "Request blocked".
 */
const HARMONY_CONTROL_TOKEN_ESCAPE_RE =
  /<\|(start|end|message|channel|constrain|return|call)\|>/g;

/**
 * Escapes reserved Harmony control tokens. Idempotent — no-op on text
 * without a reserved spelling — so it is safe to apply unconditionally.
 */
export function escapeHarmonyControlTokens(text: string): string {
  return text.replace(HARMONY_CONTROL_TOKEN_ESCAPE_RE, "<\\|$1\\|>");
}

/** Recursively escapes every string leaf of a JSON-like value. */
export function escapeHarmonyControlTokensDeep(value: unknown): unknown {
  return escapeHarmonyWalk(value, ROOT_WALK);
}

function escapeHarmonyWalk(value: unknown, walk: RewriteWalk): unknown {
  if (typeof value === "string") return escapeHarmonyControlTokens(value);
  if (Array.isArray(value)) {
    if (shouldStop(walk, value)) return value;
    const inner = descend(walk, value);
    return value.map((v) => escapeHarmonyWalk(v, inner));
  }
  if (value !== null && typeof value === "object") {
    if (shouldStop(walk, value)) return value;
    const inner = descend(walk, value);
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
      out[k] = escapeHarmonyWalk(v, inner);
    }
    return out;
  }
  return value;
}

export function tryParseJsonObject(
  text: string,
): Record<string, unknown> | undefined {
  try {
    const parsed: unknown = JSON.parse(text);
    if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
      return parsed as Record<string, unknown>;
  } catch {
    // ignore
  }
  return undefined;
}

/**
 * Builds the canonical terminal event.
 *
 * Eight call sites across the four response decoders assembled this envelope by
 * hand with the same conditional-spread idiom. Owning it here keeps one field
 * set and one omission rule: an absent optional is omitted, never emitted as an
 * explicit `undefined`.
 */
export function canonicalTerminal(input: {
  readonly sequenceNumber: number;
  readonly state: CanonicalTerminalEvent["state"];
  readonly stopReason?: CanonicalStopReason | undefined;
  readonly providerStopReason?: string | undefined;
  readonly stopDetails?: Record<string, unknown> | undefined;
  readonly usage?: UsageRecord | undefined;
  readonly responseId?: string | undefined;
  readonly eventId?: string | undefined;
}): CanonicalTerminalEvent {
  return {
    type: "terminal",
    sequence_number: input.sequenceNumber,
    state: input.state,
    ...(input.responseId === undefined ? {} : { response_id: input.responseId }),
    ...(input.eventId === undefined ? {} : { event_id: input.eventId }),
    ...(input.stopReason === undefined ? {} : { stop_reason: input.stopReason }),
    ...(input.providerStopReason === undefined
      ? {}
      : { provider_stop_reason: input.providerStopReason }),
    ...(input.stopDetails === undefined ? {} : { stop_details: input.stopDetails }),
    ...(input.usage === undefined ? {} : { usage: input.usage }),
  };
}

/**
 * Defaults a tool schema's `required` list to all declared properties while
 * preserving an explicit list, including optional properties.
 */
export function completeRequiredSchema(value: unknown): unknown {
  return completeRequiredWalk(value, ROOT_WALK);
}

function completeRequiredWalk(value: unknown, walk: RewriteWalk): unknown {
  if (Array.isArray(value)) {
    if (shouldStop(walk, value)) return value;
    const inner = descend(walk, value);
    return value.map((entry) => completeRequiredWalk(entry, inner));
  }
  if (!isRecord(value)) return value;
  if (shouldStop(walk, value)) return value;
  const inner = descend(walk, value);
  const out: Record<string, unknown> = {};
  for (const [key, child] of Object.entries(value)) {
    if (key === "properties" && isRecord(child)) {
      out.properties = Object.fromEntries(
        Object.entries(child).map(([name, schema]) => [name, completeRequiredWalk(schema, inner)]),
      );
      continue;
    }
    out[key] = completeRequiredWalk(child, inner);
  }
  if (isRecord(out.properties) && !Array.isArray(out.required)) {
    out.required = Object.keys(out.properties);
  }
  return out;
}

export type AuthHeaderShape = "authorization_bearer" | "x_api_key";

/**
 * Fallback endpoint paths for builtin providers on versioned bases.
 * Used only when an adapter omits `endpoint_paths_by_wire_family` and the
 * candidate carries no `endpoint_path` override.
 */
export const BUILTIN_DEFAULT_ENDPOINTS: Record<WireFamily, string> = {
  chat: "/chat/completions",
  responses: "/v1/responses",
  messages: "/v1/messages",
  gemini: "/api/llm/g/v1/generate",
};

/**
 * Endpoint paths for bare-host roots (operator-provided BYOK roots and the
 * generic API-key providers). A bare host has no version segment, so these
 * paths carry their own `/v1` prefix — unlike `BUILTIN_DEFAULT_ENDPOINTS`,
 * whose `chat` value assumes a base that already ends in `/v1`.
 *
 * Pinned explicitly rather than derived: these are wire bytes an operator's
 * upstream expects, so they must not shift when the builtin default changes.
 */
export const BARE_ROOT_ENDPOINTS: Partial<Record<WireFamily, string>> = {
  chat: "/v1/chat/completions",
  responses: "/v1/responses",
  messages: "/v1/messages",
};

/** An image part's provider-neutral source, resolved from any supported origin shape. */
export interface ResolvedImageSource {
  /** URL or `data:` URI. Mutually usable with `fileId`. */
  url?: string;
  /** Files API reference (Chat/Responses `file_id`). */
  fileId?: string;
  /** OpenAI `detail` hint (`auto`/`low`/`high`), when the origin carried one. */
  detail?: string;
}

/** An RFC 2397 base64 `data:` URL split into the media type and bytes it carries. */
export interface DataUrlPayload {
  /** The declared media type (the `image/png` of `data:image/png;base64,…`). */
  readonly mediaType: string;
  /** The base64 payload, with the `data:` prefix and media type removed. */
  readonly data: string;
}

/**
 * Splits a base64 `data:` URL into its media type and payload.
 *
 * Returns `undefined` for a non-`data:` URL, and for a `data:` URL that is not
 * base64-encoded: every wire this gateway speaks carries inline bytes as
 * base64, so a percent-encoded payload has no transport to land in and the
 * caller must fall back rather than forward bytes it cannot decode.
 *
 * This is the single home for the split. A codec that hand-rolls the regex
 * instead tends to keep the whole `data:` string as a URI, which the upstream
 * then cannot fetch — the attachment is lost while the request still succeeds.
 */
export function splitDataUrl(url: string): DataUrlPayload | undefined {
  const match = /^data:([^;,]+);base64,(.*)$/s.exec(url);
  const mediaType = match?.[1];
  const data = match?.[2];
  if (mediaType === undefined || data === undefined || data.length === 0) return undefined;
  return { mediaType, data };
}

/**
 * Resolves an opaque canonical `image` content part into its URL/file-id/detail
 * triple, tolerating every origin shape this codebase produces:
 * - a bare URL / `data:` URI string (an OpenAI Responses `input_image` payload
 *   is a string, and a tolerant Chat parser keeps a string `image_url` as-is)
 * - OpenAI Chat `{image_url: {url, detail?}}` / `{url, detail?}`
 * - OpenAI Responses `{type:"input_image", image_url, detail?, file_id?}`
 * - Anthropic `{type:"image", source:{type:"base64"|"url"|"file", ...}}`
 *
 * Returns `undefined` only for a shape this gateway cannot re-encode, so
 * callers can degrade explicitly instead of silently dropping the image.
 */
export function resolveImageSource(payload: unknown): ResolvedImageSource | undefined {
  // A bare string is already the URL the wire wants. Accepting it here — rather
  // than in each builder — is what keeps a string-payload image from being
  // dropped on every surface at once.
  if (typeof payload === "string") {
    return payload.length > 0 ? { url: payload } : undefined;
  }
  if (!payload || typeof payload !== "object") return undefined;
  const value = payload as Record<string, unknown>;
  const topDetail = typeof value["detail"] === "string" ? value["detail"] : undefined;
  // A Responses `input_image` may reference the Files API with a top-level
  // `file_id` and no `image_url` at all; check it before the `image_url` arms,
  // which would otherwise fall through to the generic `url`/`file_id` scan and
  // lose a payload that carries only the id.
  const topFileId = typeof value["file_id"] === "string" ? value["file_id"] : undefined;
  const nested = value["image_url"];
  if (nested === undefined && topFileId !== undefined) {
    return { fileId: topFileId, ...(topDetail === undefined ? {} : { detail: topDetail }) };
  }
  if (typeof nested === "string") {
    return { url: nested, ...(topDetail === undefined ? {} : { detail: topDetail }) };
  }
  if (nested !== null && typeof nested === "object") {
    const inner = nested as Record<string, unknown>;
    const url = typeof inner["url"] === "string" ? inner["url"] : undefined;
    const fileId = typeof inner["file_id"] === "string" ? inner["file_id"] : undefined;
    const detail = typeof inner["detail"] === "string" ? inner["detail"] : topDetail;
    if (url !== undefined || fileId !== undefined) {
      return {
        ...(url === undefined ? {} : { url }),
        ...(fileId === undefined ? {} : { fileId }),
        ...(detail === undefined ? {} : { detail }),
      };
    }
  }
  const url = typeof value["url"] === "string" ? value["url"] : undefined;
  const fileId = typeof value["file_id"] === "string" ? value["file_id"] : undefined;
  if (url !== undefined || fileId !== undefined) {
    return {
      ...(url === undefined ? {} : { url }),
      ...(fileId === undefined ? {} : { fileId }),
      ...(topDetail === undefined ? {} : { detail: topDetail }),
    };
  }
  const source = value["source"];
  if (source !== null && typeof source === "object") {
    const s = source as Record<string, unknown>;
    if (s["type"] === "base64" && typeof s["data"] === "string") {
      const mediaType = typeof s["media_type"] === "string" ? s["media_type"] : "image/png";
      return {
        url: `data:${mediaType};base64,${s["data"]}`,
        ...(topDetail === undefined ? {} : { detail: topDetail }),
      };
    }
    if (s["type"] === "url" && typeof s["url"] === "string") {
      return { url: s["url"], ...(topDetail === undefined ? {} : { detail: topDetail }) };
    }
    if (s["type"] === "file" && typeof s["file_id"] === "string") {
      return { fileId: s["file_id"], ...(topDetail === undefined ? {} : { detail: topDetail }) };
    }
  }
  return undefined;
}

export function joinUrl(base: string, path: string): string {
  const normalizedBase = base.replace(/\/+$/, "");
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
  // A base that already ends at a version segment (`…/v1`) plus a path that
  // starts with the same segment (`/v1/chat/completions`) would otherwise
  // produce `…/v1/v1/chat/completions` and 404. Drop the duplicated prefix.
  const baseVersion = normalizedBase.match(/\/(v\d+)$/);
  const version = baseVersion?.[1];
  if (version !== undefined && normalizedPath.startsWith(`/${version}/`)) {
    return `${normalizedBase}${normalizedPath.slice(version.length + 1)}`;
  }
  // A base that already is the full endpoint (`…/v1/chat/completions`) plus a
  // path that is that same endpoint is valid as-is — keep the base.
  if (normalizedPath !== "/" && normalizedBase.endsWith(normalizedPath)) {
    return normalizedBase;
  }
  return `${normalizedBase}${normalizedPath}`;
}

/**
 * Strips a single leading Bearer envelope (case-insensitive) so adapters
 * never emit `Bearer Bearer`. Delegates to `unwrapProviderToken`
 * (registry.ts), the single token-envelope implementation.
 */
export function normalizeBearerToken(raw: string): string {
  return TEXT_DECODER.decode(unwrapProviderToken(raw, "bearer"));
}

/**
 * Validates operator-configured outbound custom headers. Protected credential,
 * transport, proxy-identity, and gateway identity names can never be overridden.
 *
 * Single home for custom-header validation (RFC-token name check, 4KiB value
 * cap, control-character check, protected-name rejection).
 *
 * @param extraAllowed additional header names reserved by the calling provider
 * family beyond the base protected list — operator-supplied values for these
 * names are rejected exactly like base protected names. Despite the name,
 * these are extra non-overridable names: "allowed" refers to the family stack
 * itself, which remains allowed to set them (e.g. Claude's stainless
 * SDK-identity and client-identity headers managed by `buildClaudeHeaders`).
 */
export function filterProviderCustomHeaders(
  input: Readonly<Record<string, unknown>> | undefined,
  extraAllowed?: readonly string[],
): Record<string, string> {
  if (!input) return {};
  const extraReserved = new Set((extraAllowed ?? []).map((name) => name.toLowerCase()));
  const filtered: Record<string, string> = {};
  for (const [rawName, rawValue] of Object.entries(input)) {
    const name = rawName.toLowerCase();
    if (!HEADER_TOKEN.test(name)) {
      throw new GatewayError("invalid_request", 400, `invalid custom header name: ${rawName}`, {
        header: rawName,
      });
    }
    if (
      BASE_PROTECTED_HEADERS[name] ||
      extraReserved.has(name) ||
      name.startsWith("x-forwarded-")
    ) {
      throw new GatewayError("invalid_request", 400, `custom header is protected: ${rawName}`, {
        header: rawName,
      });
    }
    if (typeof rawValue !== "string") {
      throw new GatewayError(
        "invalid_request",
        400,
        `custom header value must be a string: ${rawName}`,
        { header: rawName },
      );
    }
    if (TEXT_ENCODER.encode(rawValue).byteLength > 4096) {
      throw new GatewayError(
        "invalid_request",
        400,
        `custom header value exceeds 4KiB: ${rawName}`,
        { header: rawName },
      );
    }
    if (HEADER_CONTROL.test(rawValue)) {
      throw new GatewayError(
        "invalid_request",
        400,
        `custom header value contains control characters: ${rawName}`,
        { header: rawName },
      );
    }
    filtered[name] = rawValue;
  }
  return filtered;
}
