/**
 * # session/harness/decoding — CONSTRAINED DECODING per lane (bead cfg-earn-retest)
 *
 * The watcher's whole benchmark claim rests on separating "the model has no edge" from "the model could
 * not SPELL the turn" (bead kestrel-y31). `artifacts/grammar/watcher.gbnf` removes the second failure
 * mode wherever the serving stack can take a CFG — llama.cpp, vLLM, Fireworks. But the two lanes the
 * #39 re-test most needs a frontier reference from, **OpenAI and Azure OpenAI, cannot take a GBNF at
 * all**: their only constrained-decoding lever is STRUCTURED OUTPUTS (a strict JSON Schema).
 *
 * So the surface ships in two machine encodings, generated from ONE table by `scripts/gen-grammar.ts`,
 * and this module is the router that hands each lane the encoding it can actually honor:
 *
 *   | lane                       | encoding                        | wire field                                        |
 *   |----------------------------|---------------------------------|---------------------------------------------------|
 *   | `fireworks`                | GBNF (`watcher.gbnf`)           | `response_format: {type:"grammar", grammar}`       |
 *   | `openai` / `azure`         | strict JSON Schema              | `response_format: {type:"json_schema", json_schema}` |
 *   | `local`                    | GBNF, served-side               | `--grammar-file` on the server (NOT a request field) |
 *   | `bedrock`/`anthropic`/…    | none                            | — (no constrained-decoding lever on these lanes)   |
 *
 * ## The schema lane is the LOOSER lane — say so, don't paper over it
 * `watcher.schema.json` is NOT the GBNF's equal (key order, whitespace, numeric bounds, and `price` as a
 * regex rather than a CFG — the artifact's own `looserThanGbnf` list is the authority). A turn that slips
 * past the schema still meets `parseTurn`/`validateAction`, which fail CLOSED and score it as an
 * `invalidTurn`. So a residual invalid-rate on the OpenAI/Azure lanes is EXPECTED and is a real datum;
 * on `fireworks` under GBNF it should be ~0, and a nonzero rate there is a bug worth chasing.
 *
 * ## Artifacts are read LAZILY and FAIL CLOSED
 * The grammar/schema are read off disk only when a lane actually asks for them (so importing the harness
 * barrel from a packaged `dist/` — which ships no `artifacts/` — never touches the filesystem). When a
 * lane DOES ask and the artifact is missing or stale, this throws: silently sending an unconstrained
 * request on a lane the caller believes is constrained would quietly reintroduce exactly the emission
 * failure the grammar exists to remove, and the run would look like a JUDGMENT result.
 */

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

/** The constrained-decoding lever a lane can honor. */
export type DecodingConstraint =
  /** A GBNF context-free grammar (llama.cpp / vLLM / Fireworks). The TIGHT encoding. */
  | { readonly mode: "gbnf"; readonly grammar: string }
  /** A strict JSON Schema (OpenAI / Azure structured outputs). The LOOSER encoding — see the module doc. */
  | { readonly mode: "json-schema"; readonly name: string; readonly schema: Record<string, unknown> };

/** Where the generated artifacts live, relative to this module (repo root `artifacts/grammar/`). */
const GRAMMAR_DIR = new URL("../../../artifacts/grammar/", import.meta.url);

function readArtifact(file: string): string {
  const path = fileURLToPath(new URL(file, GRAMMAR_DIR));
  try {
    return readFileSync(path, "utf8");
  } catch (e) {
    throw new Error(
      `constrained decoding: cannot read ${file} at ${path} — ${e instanceof Error ? e.message : String(e)}.\n` +
        `Run \`bun run gen-grammar\` to emit it. Refusing to send an UNCONSTRAINED request on a lane the ` +
        `caller asked to constrain: that would silently reintroduce the emission failures the grammar ` +
        `exists to remove (bead kestrel-y31), and the run would be misread as a judgment result.`,
    );
  }
}

let gbnfCache: string | undefined;
let schemaCache: { readonly name: string; readonly schema: Record<string, unknown> } | undefined;

/** The committed GBNF grammar text (`artifacts/grammar/watcher.gbnf`). Memoized; throws if absent. */
export function watcherGbnf(): string {
  if (gbnfCache === undefined) gbnfCache = readArtifact("watcher.gbnf");
  return gbnfCache;
}

/** The committed strict JSON Schema + its `json_schema.name`, unwrapped from the artifact's envelope
 * (`artifacts/grammar/watcher.schema.json`). Only `.schema` goes on the wire — the envelope's provenance
 * fields (`vocabHash`, `looserThanGbnf`, …) are for readers, and a strict-mode validator would reject
 * them as unknown keywords. Memoized; throws if absent or malformed. */
export function watcherJsonSchema(): { readonly name: string; readonly schema: Record<string, unknown> } {
  if (schemaCache === undefined) {
    const raw = readArtifact("watcher.schema.json");
    const env = JSON.parse(raw) as { name?: unknown; schema?: unknown };
    if (typeof env.name !== "string" || typeof env.schema !== "object" || env.schema === null) {
      throw new Error(`constrained decoding: watcher.schema.json is malformed (expected {name, schema}). Run \`bun run gen-grammar\`.`);
    }
    schemaCache = { name: env.name, schema: env.schema as Record<string, unknown> };
  }
  return schemaCache;
}

/** TEST SEAM ONLY: drop the memoized artifacts so a test can re-read them. Never called in a live run. */
export function resetDecodingCache(): void {
  gbnfCache = undefined;
  schemaCache = undefined;
}

/**
 * The constraint a lane should carry by DEFAULT — the whole point of routing this centrally is that a
 * runner (`eval-watcher.ts`) does not have to know, per provider, which encoding the wire speaks.
 *
 * `local` is deliberately ABSENT: an mlx_lm/vLLM/llama.cpp server takes its grammar at SERVE time
 * (`--grammar-file artifacts/grammar/watcher.gbnf`, see `scripts/serve/watcher-llama-server.sh`), not as
 * a request field, and sending one in the body would be ignored at best. Bedrock / Anthropic / Google
 * expose no constrained-decoding lever at all, so they get none.
 */
export function defaultDecodingFor(provider: string): DecodingConstraint | undefined {
  switch (provider) {
    case "fireworks":
      return { mode: "gbnf", grammar: watcherGbnf() };
    case "openai":
    case "azure": {
      const { name, schema } = watcherJsonSchema();
      return { mode: "json-schema", name, schema };
    }
    default:
      return undefined;
  }
}

/** The AI-SDK `providerOptions` key an OpenAI-COMPATIBLE provider reads its passthrough options from.
 * `createOpenAICompatible({ name })` sets it, and any key it does not recognize is spread verbatim into
 * the request body — which is exactly how a non-standard field like Fireworks' grammar `response_format`
 * reaches the wire through a provider package that has never heard of it. */
export const FIREWORKS_PROVIDER_NAME = "fireworks";

/** The shape of an AI-SDK language-model call's params, narrowed to the two fields this module sets. */
export interface DecodingParams {
  readonly responseFormat?: { readonly type: "json"; readonly schema: Record<string, unknown>; readonly name: string };
  readonly providerOptions?: Record<string, Record<string, unknown>>;
}

/**
 * The exact params patch a constraint becomes on an AI-SDK model call. PURE — this is the function the
 * offline tests assert against, and the same function the live middleware applies, so "the schema is
 * attached to the outbound request" is proven on the code path that actually runs.
 *
 * - **json-schema** → `responseFormat: {type:"json", schema, name}`. Both `@ai-sdk/openai` and
 *   `@ai-sdk/azure` translate that into `response_format: {type:"json_schema", json_schema:{name, strict,
 *   schema}}`, with `strict` defaulting to TRUE — which is the mode we want (it is what makes the
 *   sampler honor `enum`/`required`/`additionalProperties:false` rather than merely validate afterwards).
 *
 * - **gbnf** → `providerOptions.fireworks.response_format = {type:"grammar", grammar}`. Fireworks' OpenAI-
 *   compatible endpoint accepts a GBNF grammar in `response_format`; `@ai-sdk/openai-compatible` has no
 *   named setting for it, but it SPREADS unrecognized `providerOptions[name]` keys into the request body
 *   (and the spread lands after its own `response_format`, so this overrides it). That is the supported
 *   extra-body escape hatch, not a hack around the SDK.
 */
export function decodingParams(c: DecodingConstraint): DecodingParams {
  if (c.mode === "json-schema") {
    return { responseFormat: { type: "json", schema: c.schema, name: c.name } };
  }
  return {
    providerOptions: {
      [FIREWORKS_PROVIDER_NAME]: { response_format: { type: "grammar", grammar: c.grammar } },
    },
  };
}

/** A minimal structural view of the AI-SDK call params this middleware rewrites. */
interface CallParams {
  readonly responseFormat?: unknown;
  readonly providerOptions?: Record<string, Record<string, unknown>>;
  readonly [k: string]: unknown;
}

/**
 * Apply a constraint to a model call's params. MERGES `providerOptions` (never clobbers a caller's
 * prompt-cache breakpoint or reasoning options) and sets `responseFormat` outright (the harness never
 * sets one itself — the watcher's reply is read as text and parsed by `parseTurn`).
 *
 * Exported so the offline test can drive the EXACT transform the live middleware performs.
 */
export function applyDecoding(params: CallParams, c: DecodingConstraint): CallParams {
  const patch = decodingParams(c);
  const out: Record<string, unknown> = { ...params };
  if (patch.responseFormat !== undefined) out.responseFormat = patch.responseFormat;
  if (patch.providerOptions !== undefined) {
    const merged: Record<string, Record<string, unknown>> = { ...(params.providerOptions ?? {}) };
    for (const [provider, opts] of Object.entries(patch.providerOptions)) {
      merged[provider] = { ...(merged[provider] ?? {}), ...opts };
    }
    out.providerOptions = merged;
  }
  return out as CallParams;
}

/**
 * The constraint as an AI-SDK `LanguageModelMiddleware` — wrapped around the model in
 * `ai-sdk-client.ts#buildModel`, so the constraint rides EVERY call that model makes and cannot be
 * forgotten at a call site. Typed structurally (not against the SDK's middleware type) to keep this
 * module free of the provider packages; `ai-sdk-client.ts` is the one file allowed to import them.
 */
export function decodingMiddleware(c: DecodingConstraint): {
  readonly transformParams: (opts: { readonly params: CallParams }) => Promise<CallParams>;
} {
  return {
    transformParams: async ({ params }) => applyDecoding(params, c),
  };
}
