/**
 * # session/config — the sim config descriptor: canonicalize → ConfigId → the a57.14 envelope (kestrel-5zl.6)
 *
 * The comparison axis of Simulate is the **{@link AgentConfig} descriptor**: the same recorded tape graded
 * under N configs yields comparable {@link import("../blotter").Blotter}s, keyed by config. This module is
 * the CAPTURE side of that axis — three pure derivations, no wall clock, no RNG (RUNTIME §0):
 *
 *   1. {@link canonicalizeConfig} — `(AgentConfig) → a canonical, normalized descriptor`. Deterministic and
 *      ORDER-INSENSITIVE: the SAME field values in ANY key insertion order (top-level or nested) canonicalize
 *      byte-identically, via the Blotter's own sorted-key / drop-undefined canonicalizer (the agent-harness ADR (d)).
 *   2. {@link deriveConfigId} — `(canonical) → ConfigId = sha256(canonical)` (the agent-harness ADR (d)). The **grid
 *      column key** the config matrix (5zl.7 run-identity, 5zl.8 grid/leaderboard) keys a run by: same config
 *      ⇒ same id; ANY changed field ⇒ a DIFFERENT id (a config edit mints a new column, never contaminates an
 *      old one). The ConfigId is a SUPERSET of the envelope identity — it additionally folds the sampling axis
 *      (`temperature`/`thinkingLevel`), `format`, and the leaderboard `label`, none of which are envelope
 *      fields — so two runs differing only in `temperature` are different columns yet share every envelope field.
 *   3. {@link envelopeOf} / {@link envelopeForConfig} — map the canonical descriptor to the a57.14
 *      {@link ExperimentalEnvelope} (its INPUT half — the agent-harness ADR (d): one definition, never a second one), which
 *      the Simulate driver stamps onto the graded META so the finalized Blotter carries it. The `prompt_hash`
 *      carries a HASH, NEVER prompt/strategy content (the provenance fence, AGENTS.md §7).
 *
 * ## The descriptor → envelope field map (deliverable 1)
 *   ModelIdentity     provider → provider · model → model · version → version · trainingCutoff → training_cutoff
 *   RenderingIdentity tokenizer → tokenizer · renderingIdentity → rendering_identity   (format rides the ConfigId only)
 *   AuthorPolicy      promptHash → prompt_hash (a HASH) · toolPolicy → tool_policy · wakeSource → wake_source ·
 *                     corpusTier → corpus_tier
 *   Interface/harness face → face (http|sdk|cli|mcp) · adapter → adapter · adapterVersion → adapter_version
 *                     (m9i.2 owner tweaks — the m9i.7 tournament axes, mandatory-for-certified)
 *   injected          tokenAccounting → token_accounting   (off the deterministic record path)
 *   ConfigId-only     cachePolicy (kestrel-rul, System Profile v1.1 `cache policy`) — folds into the ConfigId
 *                     like `format`/`temperature`, but is DELIBERATELY NOT an envelope field: caching is
 *                     transport-side + off the graded path, so two policies grade identically yet mint
 *                     distinct grid columns (the m9i.5 append-only-vs-redraw / cache-on-off A/B).
 *                     cacheTtl (kestrel-wa0j.1) rides the same rule: a ConfigId-only transport axis (absent ⇒
 *                     provider default ⇒ existing ConfigIds are untouched), never an envelope field.
 *                     clockHonest + latencyBufferMs (ADR-0040 / kestrel-w7la.1) are ConfigId axes too, never
 *                     envelope fields (the a57.14 fourteen stay frozen): they fold in automatically via the
 *                     generic canonicalizer, so a clocked run — or a different buffer — NEVER shares a grid
 *                     column with an unclocked one (clock-honest wakes §4). Absent ⇒ latency-blind ⇒ every
 *                     existing ConfigId is byte-identical.
 *
 * ## Fail-closed by omission (the blast-radius rule, a57.14)
 * A NO-LLM Backtest ({@link import("./agent.ts").fixedPlanAgent} / `recordedAgent`, whose
 * {@link import("./agent.ts").BACKTEST_CONFIG} authors nothing — no `provider`/`version`/`promptHash`/…)
 * DECLARES no envelope: {@link declaresEnvelope} is false ⇒ no stamp ⇒ the Blotter stays `certified` (exactly
 * a57.14's "a bus declaring NO envelope stays certified", and "never fabricate identity"). Only a config that
 * declares authorship stamps — and a declared-but-INCOMPLETE identity (e.g. a blank `provider`) is stamped as
 * an incomplete envelope so a57.14's projector fails the Blotter closed to PROVISIONAL, never omitted (which
 * would certify-by-implication) and never silently defaulted.
 */

import type { AgentConfig } from "./agent.ts";
import type { ExperimentalEnvelope, SampledQualificationClaim, TokenAccounting } from "../bus/index.ts";
import { readSampledQualificationClaim } from "../grade/headline.ts";
import { sha256 } from "../crypto/sha256.ts";
import { canonicalize, canonicalizeJson, type Json } from "../canonical/json.ts";
import { RENDERER_REVISION } from "../frame/renderer-revision.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Canonicalization — the ONE canonical byte order (kestrel-zs2f), the SAME the Blotter serializes through
// ─────────────────────────────────────────────────────────────────────────────

/** The canonical, normalized config descriptor: a plain JSON object whose keys (top-level and nested) are
 * lexicographically sorted, so `JSON.stringify` is a byte-stable function of the DATA, never of insertion
 * order — the substrate {@link deriveConfigId} hashes. `Json` is the canonical value type
 * ({@link ../canonical/json.ts}); `deepSort` was a byte-identical copy now folded into that one owner. */
export type CanonicalConfig = { readonly [k: string]: Json };

/**
 * Canonicalize an {@link AgentConfig} into its normalized descriptor: sorted keys, dropped `undefined`,
 * recursive — the SAME {@link canonicalize} the Blotter serializes through, so the ConfigId and the Blotter
 * share one notion of "canonical". PURE and ORDER-INSENSITIVE — the same field values in any key order
 * (incl. the nested `tokenAccounting`) fold to byte-identical output. No wall clock, no RNG (RUNTIME §0).
 */
export function canonicalizeConfig(config: AgentConfig): CanonicalConfig {
  return canonicalize(config) as CanonicalConfig;
}

/**
 * Derive the {@link ConfigId} — the deterministic **grid column key** — as `sha256(canonical)` (the agent-harness ADR
 * (d)). Re-canonicalizes its input so the id is stable even if handed a raw descriptor. Same config ⇒ same
 * 64-hex id; ANY changed field ⇒ a different id. Pure (no wall clock, no RNG).
 */
export function deriveConfigId(canonical: unknown): string {
  return sha256(canonicalizeJson(canonical));
}

// ─────────────────────────────────────────────────────────────────────────────
// The CELL config axis — the full ConfigId with the replicate axes stripped (ADR-0016 ensembles)
// ─────────────────────────────────────────────────────────────────────────────

/** The config keys that are **replicate axes** — Monte-Carlo measurement noise, NOT experimental behavior.
 * A seed ensemble (`baseSeed + i`) is N replicates in ONE comparison cell whose spread is the MC error
 * (ADR-0016 §3), never N distinct subjects — so these keys fold into the full {@link deriveConfigId ConfigId}
 * / {@link import("./run-identity.ts").SimRunId SimRunId} (each seed is a distinct RUN) yet are STRIPPED from
 * the cell config axis (seeds share one {@link import("./run-identity.ts").CellKey CellKey}). Today the sole
 * replicate axis is `fillSeed`; a future replicate axis is added here (one definition, no drift). Behavioral
 * knobs (`temperature`/`thinkingLevel`) are NOT here — they split cells. */
const REPLICATE_AXIS_KEYS = ["fillSeed"] as const;

/** The canonical descriptor with the {@link REPLICATE_AXIS_KEYS} (fillSeed) removed — the substrate the CELL
 * config axis hashes. Identical to {@link canonicalizeConfig} for a config that carries no replicate axis, so
 * a seedless run's cell axis equals its full ConfigId. Pure, order-insensitive. */
export function cellCanonicalConfig(config: AgentConfig): CanonicalConfig {
  const c: Record<string, unknown> = { ...(config as unknown as Record<string, unknown>) };
  for (const k of REPLICATE_AXIS_KEYS) delete c[k];
  return canonicalizeConfig(c as unknown as AgentConfig);
}

/**
 * The **cell config-axis id**: `deriveConfigId(cellCanonicalConfig(config))` — the full ConfigId with the
 * replicate axes (fillSeed) stripped. This is the config axis the grid {@link import("./run-identity.ts").CellKey
 * CellKey} keys on (m9i.2 owner tweak 1 — temperature-only variants are DISTINCT cells — reconciled with
 * ADR-0016: seed-only variants are REPLICATES in ONE cell, not distinct columns). The ONE definition shared by
 * the driver's `cell_config_id` stamp and the grid's CellKey/cellOf, so they can never drift. For a seedless
 * config it equals the full {@link deriveConfigId ConfigId}. Pure (no wall clock, no RNG).
 */
export function cellConfigId(config: AgentConfig): string {
  return deriveConfigId(cellCanonicalConfig(config));
}

// ─────────────────────────────────────────────────────────────────────────────
// The DERIVED rendering_identity — bound to the renderer's revision, never a hand-set label (kestrel-m9i.24)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Derive the stamped `rendering_identity` from a renderer revision — the DRIFT-PROOF binding (ADR-0013(d),
 * SYSTEM-PROFILE.md `rendering_identity`). A content-addressed handle `sha256:${sha256("kestrel.rendering/" +
 * rev)}` (the codebase's {@link import("../catalog/session-catalog.ts") RenderingIdentity} handle idiom — the
 * analog of the catalog's NO-LLM machine-frame handle), NOT a free per-config string a human bumps.
 *
 * A REAL authoring run sets `renderingIdentity = deriveRenderingIdentity()`, so the identity is a function of
 * the renderer's actual revision: a renderer BEHAVIORAL change bumps {@link RENDERER_REVISION} ⇒ a DIFFERENT
 * handle ⇒ a DIFFERENT {@link deriveConfigId ConfigId}/{@link import("./run-identity.ts").CellKey CellKey} (a
 * fresh grid column, never a collapse into the old one — the drift m9i.24 closes). The default argument binds
 * to the ONE-place {@link RENDERER_REVISION} source (`src/frame/renderer-revision.ts`), so there is exactly one
 * place a renderer revision is declared. Pure — no wall clock, no RNG (RUNTIME §0).
 *
 * NO-CHURN: this is purely ADDITIVE — it does NOT touch {@link canonicalizeConfig}/{@link deriveConfigId}/
 * {@link cellConfigId}/{@link envelopeOf}, so every existing explicit-label ConfigId/CellKey/conformance-root/
 * grid pin is byte-identical; the derived value for the current renderer is a stable pin, and only a FUTURE
 * revision bump moves it.
 */
export function deriveRenderingIdentity(rendererRevision: string = RENDERER_REVISION): string {
  return `sha256:${sha256("kestrel.rendering/" + rendererRevision)}`;
}

// ─────────────────────────────────────────────────────────────────────────────
// The a57.14 envelope — the descriptor's INPUT half (one definition, the agent-harness ADR (d))
// ─────────────────────────────────────────────────────────────────────────────

/** The descriptor keys BEYOND the NO-LLM Backtest core (`model`/`tokenizer`/`format`/`temperature`/
 * `thinkingLevel`/`label`) — the ModelIdentity ⊕ AuthorPolicy ⊕ injected-accounting fields only a real
 * authoring run carries. A config declaring ANY of them declares an experimental envelope. */
const ENVELOPE_DECLARING_KEYS = [
  "provider",
  "version",
  "trainingCutoff",
  "renderingIdentity",
  "promptHash",
  "toolPolicy",
  "wakeSource",
  "corpusTier",
  "face",
  "adapter",
  "adapterVersion",
  "tokenAccounting",
] as const;

/**
 * Does this config DECLARE an experimental envelope (i.e. is it an authoring run, not a NO-LLM Backtest)?
 * True iff it carries any {@link ENVELOPE_DECLARING_KEYS} field. A {@link import("./agent.ts").BACKTEST_CONFIG}
 * carries none ⇒ false ⇒ no envelope is stamped (fail-closed by omission — the Blotter stays `certified`,
 * a57.14). A config with a BLANK required field (e.g. `provider: ""`) still DECLARES (it carries the other
 * identity fields) ⇒ true ⇒ its incomplete envelope IS stamped so the projector fails it closed.
 */
export function declaresEnvelope(config: AgentConfig): boolean {
  const c = config as unknown as Record<string, unknown>;
  return ENVELOPE_DECLARING_KEYS.some((k) => c[k] !== undefined);
}

/** A required identity string, or `""` when absent/malformed — never a fabricated plausible value. The
 * a57.14 projector treats `""` exactly like a missing field: it records a fail-closed reason NAMING it and
 * flips the Blotter to PROVISIONAL (a blank identity is never certified-by-implication). */
function idString(v: unknown): string {
  return typeof v === "string" ? v : "";
}

/** The injected token accounting mapped verbatim (off the deterministic record path). A malformed/absent
 * counter becomes `NaN` so the a57.14 projector rejects it as an incomplete envelope (fail-closed), never a
 * silent `0`. */
function tokenAccountingOf(v: unknown): TokenAccounting {
  const r = typeof v === "object" && v !== null ? (v as Record<string, unknown>) : {};
  const num = (x: unknown): number => (typeof x === "number" ? x : NaN);
  return { input: num(r.input), output: num(r.output), thinking: num(r.thinking) };
}

/**
 * Map a canonical descriptor to the a57.14 {@link ExperimentalEnvelope} (the 1:1 field map above). `format`,
 * `temperature`, `thinkingLevel`, and `label` are NOT envelope identity — they ride the ConfigId only. The
 * `prompt_hash` is carried verbatim as a HASH; this function never sees prompt/strategy content (the
 * provenance fence). Pure — a deterministic function of the descriptor.
 */
export function envelopeOf(canonical: CanonicalConfig): ExperimentalEnvelope {
  const c = canonical as Record<string, unknown>;
  return {
    provider: idString(c.provider),
    model: idString(c.model),
    version: idString(c.version),
    training_cutoff: idString(c.trainingCutoff),
    prompt_hash: idString(c.promptHash),
    tokenizer: idString(c.tokenizer),
    tool_policy: idString(c.toolPolicy),
    rendering_identity: idString(c.renderingIdentity),
    wake_source: idString(c.wakeSource),
    token_accounting: tokenAccountingOf(c.tokenAccounting),
    corpus_tier: idString(c.corpusTier),
    face: idString(c.face),
    adapter: idString(c.adapter),
    adapter_version: idString(c.adapterVersion),
  };
}

/**
 * The envelope a Simulate run stamps for `config`, or `undefined` when the config declares none (a NO-LLM
 * Backtest — {@link declaresEnvelope} false ⇒ the driver stamps no `meta.envelope` ⇒ the Blotter stays
 * `certified`, a57.14). When it declares one, the canonicalized descriptor is mapped through
 * {@link envelopeOf} (complete ⇒ the projector stamps it + certifies; incomplete ⇒ the projector fails it
 * closed to PROVISIONAL — capture happens HERE, the fail-closed evaluation stays in a57.14). Pure.
 */
export function envelopeForConfig(config: AgentConfig): ExperimentalEnvelope | undefined {
  if (!declaresEnvelope(config)) return undefined;
  return envelopeOf(canonicalizeConfig(config));
}

// ─────────────────────────────────────────────────────────────────────────────
// The SamplingConfig fill-realization seed — provenance for the sampled fill channel (kestrel-9gu.8.1)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The `runSeed` a Simulate run realizes sampled (seeded-Bernoulli) fills under, or `undefined` when the
 * config declares none — in which case **the sampled channel is OFF** (fail-closed, ADR-0016 §5): the run
 * grades the deterministic floor + analytic expected totals only, taking no draws. A malformed seed (a
 * non-finite or non-integer value) is treated exactly like an absent one — the sampler is never armed off a
 * value that would not replay byte-identically, never a silent `0`.
 *
 * This is the ONE provenance read the Simulate driver (kestrel-9gu.8.3) uses to decide whether to construct
 * the engine with a seeded rng. It reads the SamplingConfig axis, NOT the envelope: `fillSeed` folds into the
 * full {@link deriveConfigId ConfigId} / {@link import("./run-identity.ts").SimRunId SimRunId} (each seed is a
 * distinct RUN) but is a **replicate axis** STRIPPED from the cell config axis ({@link cellConfigId}), so two
 * seeds share one {@link import("./run-identity.ts").CellKey CellKey} (replicates in one cell — ADR-0016
 * ensembles) while each graded bus — carrying different realized fills — hashes to a distinct SimRunId. Pure.
 */
export function fillSeedOf(config: AgentConfig): number | undefined {
  const s = (config as { readonly fillSeed?: unknown }).fillSeed;
  return typeof s === "number" && Number.isInteger(s) ? s : undefined;
}

// ─────────────────────────────────────────────────────────────────────────────
// The sampled-channel qualification claim — the 9gu.8.6 gate input (kestrel-9gu.12)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The owner-recorded {@link SampledQualificationClaim} a config carries (`sampledQualification`), or
 * `undefined` when it declares none — in which case **the qualification gate fails closed** and the
 * strict-cross floor stays the headline (kestrel-9gu.12; 9gu.8 AC#7). A malformed claim (a missing leg, a
 * non-boolean, an empty reference) is treated exactly like an absent one — never coerced onto a passing
 * shape (RUNTIME §8, via {@link readSampledQualificationClaim}).
 *
 * Like `fillSeed`, the claim folds into the {@link deriveConfigId ConfigId} (a qualification edit mints a
 * new grid column-run) but not the a57.14 envelope. As of kestrel-9gu.12 **no study has passed** — the
 * study is kestrel-9gu.8.6 — so no shipped config declares this; the field exists so the gate seam is
 * exercised end-to-end and flipping the headline later is DATA, not code. Pure.
 */
export function sampledQualificationOf(config: AgentConfig): SampledQualificationClaim | undefined {
  return readSampledQualificationClaim((config as { readonly sampledQualification?: unknown }).sampledQualification);
}
