/**
 * # agent language card — a compiled, token-budgeted projection of the capability catalog (kestrel-gsh.4)
 *
 * The card (`docs/public/card.md` + `docs/public/card.json`) is the *compact* surface an agent author
 * loads into context before writing Kestrel. It is **not** a hand-maintained prompt: a hand-written
 * card would drift from what the runtime actually does, and a public claim a reader cannot verify is a
 * lie the repo tells (AGENTS.md). So the card is a **pure projection of the typed capability catalog**
 * (`tests/support/catalog.ts`) — the same four-axis, receipt-backed source of truth the status page
 * (gsh.2) compiles from. Every capability, surface, and fail-closed doctrine on the card is therefore
 * exactly what the catalog asserts, no more; a new capability changes the projection and a CI test
 * (`tests/docs.card.test.ts`) fails until the committed card is regenerated.
 *
 * Three properties make the card trustworthy rather than merely pretty:
 *
 *  1. **Byte-determinism.** `renderCardMarkdown` / `renderCardJson` are pure functions of the catalog
 *     (+ the package version) with stable ordering and no wall clock: same catalog ⇒ byte-identical
 *     card. The CI test re-renders and compares byte-for-byte.
 *  2. **Version coupling via a catalog hash.** {@link catalogHash} is a content hash of the catalog
 *     snapshot the card compiled from. A drifted catalog changes the hash, which changes the card,
 *     which fails the equality test — the card can never silently describe a stale catalog.
 *  3. **A tested token budget.** The card is for agents under token constraints, so its own size is a
 *     first-class property: {@link countTokens} + {@link TOKEN_BUDGET} bound the Markdown card, and a
 *     test asserts it fits. Compactness never drops the fail-closed / never-naked / marks-lie /
 *     evidence-status semantics — those are load-bearing, not decoration.
 *
 * The card also carries a handful of **object ⇄ text** pairs (ADR-0004): the typed AST object IS the
 * language and text is a byte-stable projection of it. Each pair's canonical text is *derived* by
 * running the real `print(parse(text))`, and the card's test asserts the round-trip is a fixed point —
 * the examples are executable receipts, not prose.
 *
 * This module is pure data + pure rendering over `tests/support/catalog.ts` and `src/lang`; it is NOT
 * on the runtime path and mutates nothing.
 */

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";

import type { AccessSurface, Capability, EvidenceTier, RuntimeStatus, SyntaxStatus } from "./catalog.ts";
import { CAPABILITIES, CERTIFIED_COHORT, EVIDENCE_SOURCE } from "./catalog.ts";
import { REPO_ROOT } from "./fences.ts";
import { parse, print } from "../lang/index.ts";

// ── Declared, tested economy ────────────────────────────────────────────────────

/** The command the generator writes into the card's "regenerate with" banner. */
export const REGEN_COMMAND = "bun scripts/gen-card.ts";

/**
 * The card's tokenizer, declared so its budget is reproducible. It is deliberately simple and
 * self-contained (no model-specific vocabulary, no network): one token per contiguous run of
 * word characters (`[A-Za-z0-9_]`) and one token per other non-whitespace character (each brace,
 * pipe, backtick, colon, …). It over-counts relative to a BPE tokenizer, so a card that fits this
 * budget fits a real model's budget with margin — the honest direction to err for a token guarantee.
 */
export const TOKENIZER = "kestrel-card/word-punct-v1";

/**
 * The maximum token budget for the Markdown card under {@link TOKENIZER}. The card is what an agent
 * pays for on every author turn, so this is a hard, tested ceiling — {@link countTokens} of the
 * generated `card.md` must not exceed it (see `tests/docs.card.test.ts`). Chosen with headroom over
 * the current card so incremental catalog growth does not trip CI, while still forcing a deliberate
 * decision (raise the budget, or trim the card) if the card ever bloats.
 */
export const TOKEN_BUDGET = 6000;

/** Count tokens under {@link TOKENIZER}: word-runs + individual punctuation. Pure, deterministic. */
export function countTokens(text: string): number {
  const m = text.match(/[A-Za-z0-9_]+|[^\sA-Za-z0-9_]/g);
  return m === null ? 0 : m.length;
}

// ── Version coupling: a content hash of the catalog snapshot ─────────────────────

/**
 * A canonical, order-independent serialization of one capability — the exact facts the card
 * projects. Reordering the catalog array, or reordering a capability's receipts/examples/surfaces,
 * must NOT change the hash; changing any asserted fact MUST. So every list is sorted and every field
 * is spelled out.
 */
function canonicalCapability(c: Capability): string {
  const surfaces = (Object.entries(c.access.surfaces) as [AccessSurface, string][])
    .map(([s, l]) => `${s}:${l}`)
    .sort();
  return [
    `id=${c.id}`,
    `title=${c.title}`,
    `summary=${c.summary}`,
    `public=${c.public}`,
    `syntax=${c.syntax.status}${c.syntax.note ? `|${c.syntax.note}` : ""}`,
    `runtime=${c.runtime.status}${c.runtime.note ? `|${c.runtime.note}` : ""}`,
    `evidence=${c.evidence.tier}${c.evidence.note ? `|${c.evidence.note}` : ""}`,
    `access=${surfaces.join(",")}${c.access.note ? `|${c.access.note}` : ""}`,
    `source=${[...c.receipts.source].sort().join(",")}`,
    `test=${[...c.receipts.test].sort().join(",")}`,
    `examples=${[...c.examples].sort().join(",")}`,
  ].join("\n");
}

/**
 * The catalog hash the card stamps for version coupling: a SHA-256 over the canonical serialization
 * of every capability (sorted by id) plus the certified-cohort invariant. Deterministic — no wall
 * clock, no insertion-order dependence. A drifted catalog ⇒ a different hash ⇒ a stale card ⇒ CI red.
 */
export function catalogHash(capabilities: readonly Capability[] = CAPABILITIES): string {
  const sorted = [...capabilities].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  const payload = [
    `certified_cohort=${CERTIFIED_COHORT}`,
    `evidence_source=${EVIDENCE_SOURCE}`,
    ...sorted.map(canonicalCapability),
  ].join("\n---\n");
  return createHash("sha256").update(payload, "utf8").digest("hex");
}

/** Read the language version from `package.json` (deterministic; version-matched, no wall clock). */
export function readLanguageVersion(root: string = REPO_ROOT): string {
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: string };
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
}

// ── Object ⇄ text examples (ADR-0004 round-trip, derived from the real parser/printer) ──

/** One object⇄text pair for the card. `text` is authored canonical; the round-trip is tested. */
export interface ObjectTextExample {
  readonly title: string;
  readonly note: string;
  /** Authored canonical Kestrel text; the card derives its printed form via `print(parse(text))`. */
  readonly text: string;
  /** Show the full parsed AST object on the Markdown card (small statements only, to respect budget). */
  readonly showObject: boolean;
}

/**
 * The object⇄text pairs the card demonstrates. Each `text` is already byte-canonical, so
 * `print(parse(text)) === text` (the card's test proves it). Generic tickers only (SPX/SPY) —
 * never anything that reveals a strategy (ARCHITECTURE §7).
 */
export const OBJECT_TEXT_EXAMPLES: readonly ObjectTextExample[] = [
  {
    title: "Wake — a standing subscription over the trigger algebra",
    note: "A Wake spends attention, never risk. Its `when` is the shared trigger algebra as a typed node.",
    text: "WAKE momentum\n  WHEN spot crosses above hod",
    showObject: true,
  },
  {
    title: "Grade — an imperative replay with counterfactuals",
    note: "`VS` counterfactuals and `BY` dimensions are syntax, not config; the object mirrors the text one-for-one.",
    text: "GRADE plan fade OVER 2024-01-01..2024-03-31 FILL maker-fair-v1\n  VS ungated null\n  BY vehicle",
    showObject: true,
  },
  {
    title: "Plan — bounded-risk contingent program (trigger → action → take-profit)",
    note: "The printer elides USING-covered qualifiers and normalizes spacing; the object carries the full structure (see card.json).",
    text:
      "PLAN fade budget 0.3R ttl +30m\n" +
      "  WHEN spot crosses below vwap\n" +
      "  DO buy 1 -1 P @ lean(bid, fair, 0.5) peg\n" +
      "  TP 2x frac 0.5 @ fair",
    showObject: false,
  },
  {
    title: "Plan — an equity ticket (shares, not option strikes)",
    note:
      "An equity/spot leg is `buy N shares` — the `shares` marker replaces the strike+right an option leg carries " +
      "(ADR-0017); the ambient `exec` symbol is the instrument. Omit `shares` and the parser refuses `buy N @ …`, " +
      "naming both continuations.",
    text:
      "PLAN accumulate budget 0.5R ttl +30m\n" +
      "  WHEN spot crosses above vwap\n" +
      "  DO buy 100 shares @ spot",
    showObject: false,
  },
];

/** Derive the round-trip triple for one example: the parsed object + its canonical printed text. */
export function roundTrip(ex: ObjectTextExample): { object: unknown; printed: string } {
  const object = parse(ex.text);
  return { object, printed: print(object) };
}

// ── The machine card model (what card.json serializes) ──────────────────────────

const SURFACE_ORDER: readonly AccessSurface[] = ["text", "sdk", "cli", "mcp", "adapter"];

const SYNTAX_LABEL: Record<SyntaxStatus, string> = {
  supported: "supported",
  proposed: "proposed",
  rejected: "rejected (fail-closed)",
};
const RUNTIME_LABEL: Record<RuntimeStatus, string> = {
  implemented: "implemented",
  partial: "partial",
  none: "none",
};
const EVIDENCE_LABEL: Record<EvidenceTier, string> = {
  none: "none",
  mechanical: "mechanical (practice)",
  certified: "certified",
};

/** The distinct access surfaces the catalog exposes, in canonical order (drives coverage). */
export function exposedSurfaces(capabilities: readonly Capability[] = CAPABILITIES): AccessSurface[] {
  const seen = new Set<AccessSurface>();
  for (const c of capabilities) {
    if (!c.public) continue;
    for (const s of Object.keys(c.access.surfaces) as AccessSurface[]) seen.add(s);
  }
  return SURFACE_ORDER.filter((s) => seen.has(s));
}

function accessString(c: Capability): string {
  const parts: string[] = [];
  for (const s of SURFACE_ORDER) {
    const level = c.access.surfaces[s];
    if (level !== undefined) parts.push(`${s}: ${level}`);
  }
  return parts.length === 0 ? "—" : parts.join(", ");
}

/** The sorted, public capabilities the card projects (private capabilities are omitted by design). */
export function cardCapabilities(capabilities: readonly Capability[] = CAPABILITIES): Capability[] {
  return [...capabilities]
    .filter((c) => c.public)
    .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
}

/** The fail-closed rejects, extracted from the catalog (syntax=rejected) — never dropped for brevity. */
export function rejects(capabilities: readonly Capability[] = CAPABILITIES): Capability[] {
  return cardCapabilities(capabilities).filter((c) => c.syntax.status === "rejected");
}

// ── Deterministic JSON (stable key order: `kind` first, then alphabetical) ────────

function stableStringify(value: unknown, indent = 2): string {
  const pad = (n: number): string => " ".repeat(n * indent);
  const walk = (v: unknown, depth: number): string => {
    if (v === null || typeof v !== "object") return JSON.stringify(v);
    if (Array.isArray(v)) {
      if (v.length === 0) return "[]";
      const items = v.map((x) => pad(depth + 1) + walk(x, depth + 1));
      return `[\n${items.join(",\n")}\n${pad(depth)}]`;
    }
    const obj = v as Record<string, unknown>;
    const keys = Object.keys(obj).sort((a, b) => {
      if (a === "kind") return b === "kind" ? 0 : -1;
      if (b === "kind") return 1;
      return a < b ? -1 : a > b ? 1 : 0;
    });
    if (keys.length === 0) return "{}";
    const entries = keys.map((k) => `${pad(depth + 1)}${JSON.stringify(k)}: ${walk(obj[k], depth + 1)}`);
    return `{\n${entries.join(",\n")}\n${pad(depth)}}`;
  };
  return walk(value, 0);
}

/** The non-negotiable doctrines the card must always carry (compactness never drops these). */
const NON_NEGOTIABLES: readonly { readonly rule: string; readonly detail: string }[] = [
  { rule: "Fail closed", detail: "a parse escape stands down; an unknown series de-arms with a logged reason — never a silent default." },
  { rule: "Never naked / bounded risk", detail: "a budget is a positive risk fraction (a type); a SELL is floored at intrinsic." },
  { rule: "Marks lie", detail: "a quote is a health signal, never a value: no active EXIT conditions on the mark — mid/bid/ask/last or the human words mark/premium — and mid is never a price anchor." },
  { rule: "Evidence honesty", detail: `the certified honest-tier cohort is ${CERTIFIED_COHORT}; a mechanical (practice) run is never labeled certified.` },
  { rule: "Text is a projection", detail: "the typed object model is canonical; `print(parse(text))` is a byte-stable fixed point (ADR-0004)." },
];

/** The fully-resolved, serializable card model — the single source both renderers consume. */
export interface CardModel {
  readonly languageVersion: string;
  readonly catalogHash: string;
  readonly capabilities: readonly Capability[];
  readonly surfaces: readonly AccessSurface[];
  readonly rejects: readonly Capability[];
  readonly examples: readonly ObjectTextExample[];
}

/** Build the resolved card model from the catalog + package version. Pure given its inputs. */
export function buildCardModel(
  capabilities: readonly Capability[] = CAPABILITIES,
  languageVersion: string = readLanguageVersion(),
): CardModel {
  return {
    languageVersion,
    catalogHash: catalogHash(capabilities),
    capabilities: cardCapabilities(capabilities),
    surfaces: exposedSurfaces(capabilities),
    rejects: rejects(capabilities),
    examples: OBJECT_TEXT_EXAMPLES,
  };
}

// ── Renderers (pure functions of the model) ──────────────────────────────────────

const cell = (s: string): string => s.replace(/\|/g, "\\|");

/** The four statement kinds (surfaces) of the language — a fixed pedagogical spine (CONTEXT.md). */
const STATEMENT_KINDS: readonly { readonly kind: string; readonly gloss: string }[] = [
  { kind: "VIEW", gloss: "what should I see? — panes at a token budget, materialized as a Frame" },
  { kind: "WAKE", gloss: "when should I look? — a standing subscription that spends attention, not risk" },
  { kind: "PLAN", gloss: "what may execute? — a bounded-risk contingent program the runtime fires" },
  { kind: "GRADE", gloss: "did it actually work? — an imperative replay reporting honest EV" },
];

/**
 * Render the human Markdown card. Pure: byte-identical for a given model. Uses only tables and inline
 * / `text` / `json` code fences (never a bare ```kestrel fence) so it adds NO managed fence to the
 * gsh.1 corpus — its examples are governed by this card's own round-trip test instead.
 */
export function renderCardMarkdown(model: CardModel): string {
  const out: string[] = [];
  const L = (line = ""): void => void out.push(line);

  L("<!-- GENERATED FILE — do not edit by hand.");
  L(`     Regenerate from the typed capability catalog with: ${REGEN_COMMAND}`);
  L("     Source of truth: tests/support/catalog.ts · renderer: tests/support/card.ts");
  L("     A CI test (tests/docs.card.test.ts) asserts this file equals a fresh regeneration,");
  L("     that it fits the declared token budget, and that every object⇄text pair round-trips. -->");
  L();
  L("# Kestrel agent language card");
  L();
  L(
    "A compact, receipt-backed reference for **authoring Kestrel** — one typed, token-efficient " +
      "language for agentic trading with four statement kinds over one lexical core. This card is a " +
      "**compiled projection of the capability catalog** (`tests/support/catalog.ts`), the same " +
      "four-axis source of truth the [status page](./status.md) derives from. It claims nothing the " +
      "catalog does not: every capability below is graded on syntax · runtime · evidence · access and " +
      "backed by a source (`src/`) and test (`tests/`) receipt.",
  );
  L();
  L(`- **Language version:** \`${model.languageVersion}\``);
  L(`- **Catalog hash:** \`sha256:${model.catalogHash.slice(0, 16)}\` (full hash in \`card.json\`) — a drifted catalog invalidates this card.`);
  L(`- **Token budget:** ≤ ${TOKEN_BUDGET} tokens under \`${TOKENIZER}\` (enforced in CI).`);
  L(`- **Certified honest-tier cohort:** ${CERTIFIED_COHORT} — every \`mechanical (practice)\` row below is a mechanically-graded practice run, never certified.`);
  L();

  L("## Non-negotiables");
  L();
  L("Compactness never drops these — they are the doctrine the runtime enforces, not style.");
  L();
  for (const n of NON_NEGOTIABLES) L(`- **${n.rule}.** ${n.detail}`);
  L();

  L("## Statement kinds (surfaces)");
  L();
  L("| Kind | What it answers |");
  L("| --- | --- |");
  for (const s of STATEMENT_KINDS) L(`| \`${s.kind}\` | ${cell(s.gloss)} |`);
  L();

  L("## Capabilities — four axes");
  L();
  L(
    "A feature can *parse* (syntax) yet have no *runtime*, no graded *evidence*, and only partial " +
      "*access*. These four facts are independent; a single \"supported\" flag would collapse them into " +
      "one flattering lie.",
  );
  L();
  L("| Capability | id | Syntax | Runtime | Evidence | Access |");
  L("| --- | --- | --- | --- | --- | --- |");
  for (const c of model.capabilities) {
    L(
      `| ${cell(c.title)} | \`${c.id}\` | ${SYNTAX_LABEL[c.syntax.status]} | ${RUNTIME_LABEL[c.runtime.status]} | ${EVIDENCE_LABEL[c.evidence.tier]} | ${cell(accessString(c))} |`,
    );
  }
  L();
  L(`**Access surfaces exposed:** ${model.surfaces.map((s) => `\`${s}\``).join(" · ")}.`);
  L();

  L("## What each capability is");
  L();
  for (const c of model.capabilities) {
    L(`- **${cell(c.title)}** (\`${c.id}\`) — ${cell(c.summary)}`);
  }
  L();

  L("## Fail-closed rejects");
  L();
  L("These are refused at parse time — the illegal statement never reaches the engine.");
  L();
  L("| id | Doctrine |");
  L("| --- | --- |");
  for (const c of model.rejects) {
    const note = c.syntax.note ?? c.summary;
    L(`| \`${c.id}\` | ${cell(note)} |`);
  }
  L();

  L("## Object ⇄ text (ADR-0004 round-trip)");
  L();
  L(
    "The typed AST object **is** the language; text is a byte-stable projection of it. For every pair " +
      "below `print(parse(text)) === text` (the card's test proves it). Objects are shown as JSON; the " +
      "full objects for all pairs are in `card.json`.",
  );
  L();
  for (const ex of model.examples) {
    const { object, printed } = roundTrip(ex);
    L(`### ${cell(ex.title)}`);
    L();
    L(ex.note);
    L();
    L("Canonical text:");
    L();
    L("```text");
    L(printed);
    L("```");
    L();
    if (ex.showObject) {
      L("Object (parsed AST):");
      L();
      L("```json");
      L(stableStringify(object));
      L("```");
      L();
    }
  }

  L("## Provenance");
  L();
  L(
    "Generated from `tests/support/catalog.ts` by `tests/support/card.ts`. Regenerate with " +
      `\`${REGEN_COMMAND}\`. The committed card is asserted byte-identical to a fresh regeneration in ` +
      "`tests/docs.card.test.ts`; if you change the catalog, regenerate or CI fails.",
  );
  L();

  return out.join("\n") + "\n";
}

/**
 * Render the machine card (card.json). Pure given `model` + the measured Markdown token count.
 * `markdownTokens` is passed in (not recomputed here) so the two files are generated from one
 * measurement and stay mutually consistent.
 */
export function renderCardJson(model: CardModel, markdownTokens: number): string {
  const doc = {
    $generated: {
      note: "GENERATED FILE — do not edit by hand.",
      regenerate: REGEN_COMMAND,
      sourceOfTruth: "tests/support/catalog.ts",
      renderer: "tests/support/card.ts",
      checkedBy: "tests/docs.card.test.ts",
    },
    languageVersion: model.languageVersion,
    catalogHash: `sha256:${model.catalogHash}`,
    tokenizer: TOKENIZER,
    tokenBudget: TOKEN_BUDGET,
    markdownTokens,
    certifiedCohort: CERTIFIED_COHORT,
    evidenceSource: EVIDENCE_SOURCE,
    nonNegotiables: NON_NEGOTIABLES.map((n) => ({ rule: n.rule, detail: n.detail })),
    statementKinds: STATEMENT_KINDS.map((s) => ({ kind: s.kind, answers: s.gloss })),
    surfaces: model.surfaces,
    capabilities: model.capabilities.map((c) => ({
      id: c.id,
      title: c.title,
      summary: c.summary,
      syntax: { status: c.syntax.status, ...(c.syntax.note ? { note: c.syntax.note } : {}) },
      runtime: { status: c.runtime.status, ...(c.runtime.note ? { note: c.runtime.note } : {}) },
      evidence: { tier: c.evidence.tier, ...(c.evidence.note ? { note: c.evidence.note } : {}) },
      access: {
        surfaces: c.access.surfaces,
        ...(c.access.note ? { note: c.access.note } : {}),
      },
      receipts: { source: [...c.receipts.source].sort(), test: [...c.receipts.test].sort() },
      examples: [...c.examples].sort(),
    })),
    rejects: model.rejects.map((c) => ({ id: c.id, doctrine: c.syntax.note ?? c.summary })),
    objectText: model.examples.map((ex) => {
      const { object, printed } = roundTrip(ex);
      return { title: ex.title, note: ex.note, text: printed, object };
    }),
  };
  return stableStringify(doc) + "\n";
}

/** Build both card files from the catalog + package version. The generator and tests share this. */
export function renderCard(
  capabilities: readonly Capability[] = CAPABILITIES,
  languageVersion: string = readLanguageVersion(),
): { markdown: string; json: string; markdownTokens: number; model: CardModel } {
  const model = buildCardModel(capabilities, languageVersion);
  const markdown = renderCardMarkdown(model);
  const markdownTokens = countTokens(markdown);
  const json = renderCardJson(model, markdownTokens);
  return { markdown, json, markdownTokens, model };
}
