/**
 * # grade/receipt — the immutable agent PROPOSAL / DECLINE / STAND-DOWN receipt (kestrel-a57.5)
 *
 * A **receipt** is the immutable, content-addressed record of one agent DECISION taken at a point in a
 * Session: a plan it **armed** (accept), a plan it **declined**, or a **stand-down** (chose not to act).
 * It is the curation-honest counterpart to the {@link ../blotter Blotter}: the Blotter records what the
 * engine DID; a receipt records what the agent DECIDED, including the decisions that produced no order.
 *
 * **Curation honesty (the a57.5 doctrine).** A survivor-only ledger — one that keeps only the plans the
 * agent armed — cannot grade *curation*: you cannot tell a good selector from a lucky one without the
 * declines and stand-downs it left on the table. So declines and stand-downs are **MANDATORY**, not
 * optional, and every receipt names the plan under decision (`plan`) so a declined/stood-down plan is a
 * first-class row of the record, never dropped. (The subject plan rides the receipt directly — rather than
 * living only in a frozen armory default — so curation is structural.)
 *
 * **What a receipt pins.** Four content hashes make a decision replayable and attributable:
 *   • **percept-hash** — the perceptual state the agent acted on. Today this is the sha256 of the canonical
 *     bus PREFIX the agent could have perceived (everything with `seq < proposed_seq`); the FULL percept-v5
 *     Frame (a rendered {@link ../frame Frame}) is a documented FUTURE seam (see {@link PerceptProjection}).
 *   • **plan-hash** — sha256 of the CANONICAL plan text, `print(parse(text))` (ADR-0004): text is a
 *     projection of the typed core, so the hash is invariant to whitespace/formatting, only the plan.
 *   • **counterfactual-hash** — the null/baseline the decision was measured against (ADR-0006:
 *     counterfactuals are syntax — `ungated | null | bracket`), pinned so a grade cannot silently swap it.
 *   • the **thesis axes** — the structured claim (when/where/how + conviction) — and the
 *     **measurement-version** pinning (which fill-model / grader / envelope version graded it), because EVs
 *     across measurement versions refuse naive comparison (ADR-0006).
 *
 * **Discipline (mirrors the Blotter, ADR-0011).** A receipt is IMMUTABLE and byte-stable: it is
 * content-addressed (`receipt_id = "receipt:" + identity_sha256`, the sha256 over its own canonical bytes),
 * so two byte-identical decisions share an id and any mutation changes the id. {@link serializeReceipt}
 * is its one canonical serialization (lexicographically ordered keys, compact, one trailing newline) — the
 * substrate a determinism check byte-compares. No wall clock, no RNG (RUNTIME §0). The pure PROJECTOR that
 * lifts receipts off a Session bus is {@link ./receipts.ts}.
 */

import { parse, print } from "../lang/index.ts";
import { sha256 } from "../crypto/sha256.ts";
import { canonicalizeJson, jsonHash } from "../canonical/json.ts";
import { PROBABILITY_SUM_TOLERANCE } from "../protocol/grade.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Determinism helpers (no wall clock, no RNG — RUNTIME §0)
// ─────────────────────────────────────────────────────────────────────────────

/** sha256 hex of a UTF-8 string (deterministic). Re-exported from the portable {@link ../crypto/sha256.ts}
 * digest so a receipt hashes byte-identically under Bun and inside a Worker isolate (kestrel-alw.17). */
export { sha256 };

// The canonical byte order (recursively sorted keys, `undefined` dropped) and its content hash — one
// owner in {@link ../canonical/json.ts} (kestrel-zs2f); `canonicalizeJson`/`jsonHash` were copy-pasted
// here, byte-identical to the Blotter's, and are now a single import.

/** Is `s` a lowercase-hex sha256? (fail-closed guard on any hash-shaped field). */
export function isSha256(s: unknown): s is string {
  return typeof s === "string" && s.length === 64 && /^[0-9a-f]{64}$/.test(s);
}

/** Raised when a receipt is malformed, contradictory, or violates the curation-honesty doctrine.
 * The projector catches this and fails CLOSED (a malformed decision degrades to a logged stand-down;
 * it never crashes and never silently drops the row). */
export class ReceiptValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ReceiptValidationError";
  }
}

/** Raised specifically when a plan's SOURCE text fails to parse — the "parse escape → STAND_DOWN" trigger
 * (AGENTS.md). A distinct subclass so the projector's degrade-to-stand-down branch keys off the TYPE, not a
 * human-readable message substring (a reworded message can never silently break the mandatory-row guarantee).
 * It IS a {@link ReceiptValidationError}, so every existing fail-closed catch still catches it. */
export class PlanParseError extends ReceiptValidationError {
  constructor(message: string) {
    super(message);
    this.name = "PlanParseError";
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Enumerations
// ─────────────────────────────────────────────────────────────────────────────

/** The receipt schema version. Bumped on any breaking receipt-shape change. */
export const RECEIPT_SCHEMA = "1.0.0" as const;

/**
 * What the agent DID with a candidate at this decision point:
 *   • `accept`         — armed a proposed plan as-is (the survivor).
 *   • `reparameterize` — armed a proposed plan with adjusted parameters (still a survivor, a new plan-hash).
 *   • `novel_plan`     — armed a freshly authored plan (not from the armory).
 *   • `decline`        — considered a plan and chose NOT to arm it (a curation decision — MANDATORY to record).
 *   • `stand_down`     — chose not to act at all (fail-closed default; AGENTS.md "parse escape → STAND_DOWN").
 */
export type DecisionKind = "accept" | "reparameterize" | "novel_plan" | "decline" | "stand_down";

/** The legal decision kinds, for fail-closed validation. */
export const DECISION_KINDS: ReadonlySet<DecisionKind> = new Set<DecisionKind>([
  "accept",
  "reparameterize",
  "novel_plan",
  "decline",
  "stand_down",
]);

/** The decision kinds that ARM a plan (produce a live, executable standing) — as opposed to the no-action
 * curation decisions (`decline`, `stand_down`) that leave nothing armed but MUST still be recorded. */
export const ARMING_DECISIONS: ReadonlySet<DecisionKind> = new Set<DecisionKind>([
  "accept",
  "reparameterize",
  "novel_plan",
]);

/** The counterfactual (null/baseline) a decision was measured against — kestrel's counterfactual syntax
 * (ADR-0006), the `Counterfactual` AST minus its `cf-` prefix: `ungated | null | bracket`. */
export type CounterfactualKind = "ungated" | "null" | "bracket";

/** The legal counterfactual kinds, for fail-closed validation. */
export const COUNTERFACTUAL_KINDS: ReadonlySet<CounterfactualKind> = new Set<CounterfactualKind>([
  "ungated",
  "null",
  "bracket",
]);

// ─────────────────────────────────────────────────────────────────────────────
// Nested shapes
// ─────────────────────────────────────────────────────────────────────────────

/**
 * How the percept the agent acted on was projected. **Today** the projector renders the perceptual state as
 * the canonical bus PREFIX (`kind: "bus-prefix"`) — the exact bus bytes the agent could have perceived up to
 * its decision — and hashes those. The FULL percept-v5 Frame (a rendered {@link ../frame Frame} at the
 * decision moment) is a documented FUTURE seam (4gl): when it lands, `kind` becomes `"frame"` and the same
 * `sha256` slot pins the Frame's canonical bytes. `renderer_version` pins WHICH projector produced the
 * bytes, so a percept-hash never silently changes meaning across a projector revision.
 */
export interface PerceptProjection {
  /** `bus-prefix` today (the available representation); `frame` when the percept-v5 Frame lands (seam). */
  readonly kind: "bus-prefix" | "frame";
  /** The projector/renderer version that produced the hashed bytes (pins meaning across revisions). */
  readonly renderer_version: string;
}

/**
 * The perceptual state the agent acted on — the **percept-hash** plus its provenance. `sha256` is the
 * percept-hash; `byte_count` and `seq_watermark` pin exactly WHAT was hashed (the canonical bus prefix and
 * the last seq it contained), so a grader can regenerate and byte-compare it. See {@link PerceptProjection}
 * for the today→Frame seam.
 */
export interface Percept {
  /** The percept-hash: sha256 of the projected perceptual bytes (today, the canonical bus prefix). */
  readonly sha256: string;
  /** The byte length of the hashed perceptual representation. */
  readonly byte_count: number;
  /** The greatest bus `seq` the perceptual representation contained (the information watermark). */
  readonly seq_watermark: number;
  /** How the percept was projected (bus-prefix today; frame is the documented seam). */
  readonly projection: PerceptProjection;
}

/**
 * The plan under decision — its CANONICAL text and its **plan-hash**. `text` is `print(parse(source))`
 * (ADR-0004), so `sha256` is invariant to formatting and pins only the plan's meaning. Both are `null` only
 * for a bare `stand_down` that names no candidate; every other decision (accept/decline/reparam/novel) MUST
 * carry the plan so the record is curation-honest (a declined plan is never dropped).
 */
export interface ReceiptPlan {
  /** The canonical plan text (`print(parse(source))`), or `null` for a candidate-less stand-down. */
  readonly text: string | null;
  /** The plan-hash: sha256 of `text`, or `null` when `text` is `null`. */
  readonly sha256: string | null;
}

/**
 * The counterfactual (null/baseline) the decision was measured against, plus its **counterfactual-hash**.
 * `kind` is the counterfactual syntax (ADR-0006: `ungated | null | bracket`); `sha256` pins its canonical
 * descriptor so a grade cannot silently substitute a weaker baseline. Every receipt carries one — a
 * decision measured against nothing is not gradable.
 */
export interface ReceiptCounterfactual {
  readonly kind: CounterfactualKind;
  /** The counterfactual-hash: sha256 over the canonical counterfactual descriptor. */
  readonly sha256: string;
}

/** A single thesis-axis value — a structured, legible claim token (never free markdown). */
export type ThesisValue = string | number | boolean | null;

/**
 * The **thesis axes** — the agent's structured claim, on three orthogonal axes plus conviction. WHEN the
 * setup holds, WHERE it expresses, HOW it is expressed; `conviction` in [0,1]; and
 * `day_type_probabilities` a distribution over named day-types summing to 1. This is the gradable claim: a
 * grade can cluster receipts BY any axis (names are data, ADR-0006) to discover which theses actually pay.
 */
export interface ThesisAxes {
  readonly when: {
    readonly session_phase: ThesisValue;
    readonly regime: ThesisValue;
    readonly event: ThesisValue;
    readonly trend_state: ThesisValue;
    readonly volatility_state: ThesisValue;
  };
  readonly where: {
    readonly product: ThesisValue;
    readonly direction: ThesisValue;
    readonly strike: ThesisValue;
    readonly moneyness: ThesisValue;
    readonly book_state: ThesisValue;
  };
  readonly how: {
    readonly entry_method: ThesisValue;
    readonly size_contracts: ThesisValue;
    readonly reload: ThesisValue;
    readonly invalidation: ThesisValue;
    readonly exit: ThesisValue;
  };
  /** Conviction in [0,1]. */
  readonly conviction: number;
  /** A non-empty distribution over named day-types, summing to 1 (± 1e-9). */
  readonly day_type_probabilities: Readonly<Record<string, number>>;
}

/** The exact axis membership each thesis section must carry (fail-closed: no more, no fewer keys). */
const THESIS_AXIS_FIELDS: Readonly<Record<"when" | "where" | "how", readonly string[]>> = {
  when: ["session_phase", "regime", "event", "trend_state", "volatility_state"],
  where: ["product", "direction", "strike", "moneyness", "book_state"],
  how: ["entry_method", "size_contracts", "reload", "invalidation", "exit"],
};

/**
 * The measurement-version pinning — WHICH versioned machinery graded (or will grade) the
 * decision. EVs across measurement versions refuse naive comparison (ADR-0006), so every version that could
 * move a number is pinned on the receipt: the `fill_model`, the `commission_model`, the `bus_fidelity`
 * level, the `risk_envelope`, and the `grader`.
 */
export interface MeasurementVersions {
  readonly fill_model: string;
  readonly commission_model: string;
  readonly bus_fidelity: string;
  readonly risk_envelope: string;
  readonly grader: string;
}

const MEASUREMENT_VERSION_FIELDS: readonly (keyof MeasurementVersions)[] = [
  "fill_model",
  "commission_model",
  "bus_fidelity",
  "risk_envelope",
  "grader",
];

// ─────────────────────────────────────────────────────────────────────────────
// The receipt
// ─────────────────────────────────────────────────────────────────────────────

/**
 * One immutable agent decision receipt (kestrel-a57.5). Content-addressed: `receipt_id` is
 * `"receipt:" + identity_sha256`, and `identity_sha256` is the sha256 over the receipt's own canonical
 * bytes with `identity_sha256`/`receipt_id` removed — so the id IS the content and any mutation changes it.
 * Byte-stable under {@link serializeReceipt}. Carries the plan under decision (curation honesty), the four
 * content hashes (percept / plan / counterfactual), the thesis axes, and the measurement-version pinning.
 */
export interface ProposalReceipt {
  readonly schema_version: typeof RECEIPT_SCHEMA;
  readonly record_type: "ProposalReceipt";
  /** `"receipt:" + identity_sha256`. */
  readonly receipt_id: string;
  /** sha256 over the canonical body (this receipt minus `identity_sha256`/`receipt_id`). */
  readonly identity_sha256: string;
  /** The session this decision belongs to (opaque calendar token, YYYY-MM-DD). */
  readonly session_date: string;
  /** The bus `ts` at which the decision was proposed (in sim, the bus clock — RUNTIME §0). */
  readonly proposed_at: number;
  /** The bus `seq` at which the decision was recorded (strictly after `percept.seq_watermark`). */
  readonly proposed_seq: number;
  /** The `ts` of the last information the agent could have used (≤ `proposed_at`). */
  readonly information_cutoff: number;
  readonly decision_kind: DecisionKind;
  readonly percept: Percept;
  readonly plan: ReceiptPlan;
  readonly counterfactual: ReceiptCounterfactual;
  readonly measurement_versions: MeasurementVersions;
  readonly thesis: ThesisAxes;
  /** A free author note, or `null`. Never load-bearing for identity beyond its bytes. */
  readonly rationale: string | null;
}

/** The content-addressing input for {@link createReceipt}: the raw decision, before hashing/identity. The
 * builder canonicalizes the plan text (`print(parse)`), computes the four hashes, and seals the identity. */
export interface ReceiptInput {
  readonly session_date: string;
  readonly proposed_at: number;
  readonly proposed_seq: number;
  readonly information_cutoff: number;
  readonly decision_kind: DecisionKind;
  readonly percept: Percept;
  /** The plan SOURCE text (canonicalized by the builder), or `null` for a candidate-less stand-down. */
  readonly plan_text: string | null;
  readonly counterfactual: CounterfactualKind;
  readonly measurement_versions: MeasurementVersions;
  readonly thesis: ThesisAxes;
  readonly rationale?: string | null;
}

// ─────────────────────────────────────────────────────────────────────────────
// Hashing primitives (the four content hashes)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The **plan-hash**: sha256 of the CANONICAL plan text (ADR-0004). The source is round-tripped through
 * `print(parse(source))` so the hash pins the plan's MEANING, not its formatting. Returns the canonical
 * text alongside the hash. Fail-closed: a parse escape raises {@link ReceiptValidationError} (the projector
 * degrades such a decision to a logged stand-down — AGENTS.md "parse escape → STAND_DOWN").
 */
export function planHash(source: string): { text: string; sha256: string } {
  let canonical: string;
  try {
    canonical = print(parse(source));
  } catch (err) {
    throw new PlanParseError(
      `plan text does not parse (fail-closed → stand-down): ${(err as Error).message}`,
    );
  }
  return { text: canonical, sha256: sha256(canonical) };
}

/** The **counterfactual-hash**: sha256 over the canonical counterfactual descriptor. Pins the exact
 * null/baseline the decision was measured against so a grade cannot substitute a weaker one. */
export function counterfactualHash(kind: CounterfactualKind): string {
  return jsonHash({ counterfactual: kind });
}

// ─────────────────────────────────────────────────────────────────────────────
// createReceipt — the pure content-addressing builder
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Build one immutable, content-addressed {@link ProposalReceipt} from a raw decision. Pure and
 * deterministic (no wall clock, no RNG): the SAME input yields a byte-identical receipt. Canonicalizes the
 * plan text, computes the percept/plan/counterfactual hashes, assembles the body, seals the identity
 * (`identity_sha256` = sha256 over the canonical body; `receipt_id` = `"receipt:" + identity`), and
 * validates fail-closed. Throws {@link ReceiptValidationError} on any contradiction.
 */
export function createReceipt(input: ReceiptInput): ProposalReceipt {
  if (!DECISION_KINDS.has(input.decision_kind)) {
    throw new ReceiptValidationError(`unknown decision_kind ${JSON.stringify(input.decision_kind)}`);
  }
  if (!COUNTERFACTUAL_KINDS.has(input.counterfactual)) {
    throw new ReceiptValidationError(`unknown counterfactual ${JSON.stringify(input.counterfactual)}`);
  }

  // The plan under decision. Curation honesty: only a bare stand-down may name no plan; every other
  // decision — INCLUDING a decline — MUST carry the plan so it is a first-class row, never dropped.
  let plan: ReceiptPlan;
  if (input.plan_text === null) {
    if (input.decision_kind !== "stand_down") {
      throw new ReceiptValidationError(
        `${input.decision_kind} must carry the plan under decision (curation honesty: a declined/armed plan is never dropped)`,
      );
    }
    plan = { text: null, sha256: null };
  } else {
    const h = planHash(input.plan_text);
    plan = { text: h.text, sha256: h.sha256 };
  }

  const body = {
    schema_version: RECEIPT_SCHEMA,
    record_type: "ProposalReceipt" as const,
    session_date: input.session_date,
    proposed_at: input.proposed_at,
    proposed_seq: input.proposed_seq,
    information_cutoff: input.information_cutoff,
    decision_kind: input.decision_kind,
    percept: input.percept,
    plan,
    counterfactual: { kind: input.counterfactual, sha256: counterfactualHash(input.counterfactual) },
    measurement_versions: input.measurement_versions,
    thesis: input.thesis,
    rationale: input.rationale ?? null,
  };

  const identity = jsonHash(body);
  const receipt: ProposalReceipt = {
    ...body,
    identity_sha256: identity,
    receipt_id: `receipt:${identity}`,
  };
  validateReceipt(receipt);
  return receipt;
}

// ─────────────────────────────────────────────────────────────────────────────
// validateReceipt — fail-closed integrity + doctrine check
// ─────────────────────────────────────────────────────────────────────────────

function requireFiniteNumber(value: unknown, path: string): number {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new ReceiptValidationError(`${path} must be a finite number`);
  }
  return value;
}

/** Validate a receipt's integrity (content-addressing, causal ordering, thesis membership) and its
 * curation-honesty doctrine. Pure and total; throws {@link ReceiptValidationError} on any violation. */
export function validateReceipt(r: ProposalReceipt): void {
  if (r.record_type !== "ProposalReceipt") throw new ReceiptValidationError("record_type must be ProposalReceipt");
  if (r.schema_version !== RECEIPT_SCHEMA) {
    throw new ReceiptValidationError(`unsupported receipt schema_version ${JSON.stringify(r.schema_version)}`);
  }
  if (!DECISION_KINDS.has(r.decision_kind)) throw new ReceiptValidationError("unknown decision_kind");

  // Content-addressing: the id IS the content.
  if (!isSha256(r.identity_sha256)) throw new ReceiptValidationError("identity_sha256 must be a lowercase sha256");
  if (r.receipt_id !== `receipt:${r.identity_sha256}`) throw new ReceiptValidationError("receipt_id/identity_sha256 mismatch");
  const { identity_sha256: _id, receipt_id: _rid, ...body } = r;
  if (jsonHash(body) !== r.identity_sha256) {
    throw new ReceiptValidationError("receipt content does not match its immutable identity");
  }

  // Causal ordering (RUNTIME §0): the decision follows the information it used.
  requireFiniteNumber(r.proposed_at, "proposed_at");
  requireFiniteNumber(r.information_cutoff, "information_cutoff");
  if (r.information_cutoff > r.proposed_at) {
    throw new ReceiptValidationError("information_cutoff must be no later than proposed_at");
  }
  if (!Number.isInteger(r.proposed_seq) || r.proposed_seq < 0) {
    throw new ReceiptValidationError("proposed_seq must be a non-negative integer");
  }

  // Percept.
  if (!isSha256(r.percept.sha256)) throw new ReceiptValidationError("percept.sha256 must be a lowercase sha256");
  if (!Number.isInteger(r.percept.byte_count) || r.percept.byte_count < 0) {
    throw new ReceiptValidationError("percept.byte_count must be a non-negative integer");
  }
  if (!Number.isInteger(r.percept.seq_watermark) || r.percept.seq_watermark < 0) {
    throw new ReceiptValidationError("percept.seq_watermark must be a non-negative integer");
  }
  if (r.percept.seq_watermark >= r.proposed_seq) {
    throw new ReceiptValidationError("percept.seq_watermark must be strictly before proposed_seq (a decision follows its percept)");
  }

  // Plan under decision — curation honesty.
  if (r.plan.text === null) {
    if (r.plan.sha256 !== null) throw new ReceiptValidationError("null plan text cannot carry a plan-hash");
    if (r.decision_kind !== "stand_down") {
      throw new ReceiptValidationError(`${r.decision_kind} must carry the plan under decision (curation honesty)`);
    }
  } else {
    if (!isSha256(r.plan.sha256)) throw new ReceiptValidationError("plan.sha256 must be a lowercase sha256");
    // The plan-hash must be the hash of the CANONICAL text (idempotent under print(parse)).
    const h = planHash(r.plan.text);
    if (h.text !== r.plan.text) throw new ReceiptValidationError("plan.text is not canonical (must be print(parse(text)))");
    if (h.sha256 !== r.plan.sha256) throw new ReceiptValidationError("plan.sha256 does not match plan.text");
  }

  // Counterfactual-hash.
  if (!COUNTERFACTUAL_KINDS.has(r.counterfactual.kind)) throw new ReceiptValidationError("unknown counterfactual kind");
  if (counterfactualHash(r.counterfactual.kind) !== r.counterfactual.sha256) {
    throw new ReceiptValidationError("counterfactual.sha256 does not match its kind");
  }

  // Measurement-version pinning — every version present and non-empty.
  for (const f of MEASUREMENT_VERSION_FIELDS) {
    const v = r.measurement_versions[f];
    if (typeof v !== "string" || v.trim().length === 0) {
      throw new ReceiptValidationError(`measurement_versions.${f} must be a non-empty string`);
    }
  }

  // Thesis axes — exact membership + conviction/probabilities well-formed.
  for (const axis of ["when", "where", "how"] as const) {
    const section = r.thesis[axis] as Record<string, unknown>;
    const keys = Object.keys(section).sort();
    const want = [...THESIS_AXIS_FIELDS[axis]].sort();
    if (keys.length !== want.length || keys.some((k, i) => k !== want[i])) {
      throw new ReceiptValidationError(`thesis.${axis} must contain exactly ${want.join(", ")}`);
    }
  }
  const conviction = requireFiniteNumber(r.thesis.conviction, "thesis.conviction");
  if (conviction < 0 || conviction > 1) throw new ReceiptValidationError("thesis.conviction must be in [0,1]");
  const probs = r.thesis.day_type_probabilities;
  const names = Object.keys(probs);
  if (names.length === 0) throw new ReceiptValidationError("thesis.day_type_probabilities must be non-empty");
  let sum = 0;
  for (const name of names) {
    const p = requireFiniteNumber(probs[name], `thesis.day_type_probabilities.${name}`);
    if (p < 0 || p > 1) throw new ReceiptValidationError(`thesis.day_type_probabilities.${name} must be in [0,1]`);
    sum += p;
  }
  if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE)
    throw new ReceiptValidationError("thesis.day_type_probabilities must sum to 1");
}

// ─────────────────────────────────────────────────────────────────────────────
// Canonical serialization (mirrors blotter/serialize)
// ─────────────────────────────────────────────────────────────────────────────

/** Serialize one receipt to its canonical bytes: deterministic machine-JSON (lexicographically ordered
 * keys, compact, one trailing newline). Pure and total — the same receipt always produces byte-identical
 * text, the substrate a determinism check byte-compares. */
export function serializeReceipt(receipt: ProposalReceipt): string {
  return canonicalizeJson(receipt) + "\n";
}
