/**
 * # frame/template-record — the TemplateRecord evidence ledger (SKELETON: types + storage, ADR-0041 §2, kestrel-wa0j.26)
 *
 * `PerceptProfile` reduced to nothing more than a **TemplateRecord set** (ADR-0041 §2): the evidence
 * ledger where every founder — or agent-authored — `.kestrel` template enters as a HYPOTHESIS carrying
 * a cell-keyed ledger entry, NEVER a blessed default. This module is the record family + the in-memory
 * store + fail-closed (de)serialization. It is a **skeleton by design**: no grading logic, no promotion
 * logic, no harness — those land later (the matched-set harness owns selection/grading; ADR-0029 owns
 * owner-gated freezes). What ships here is the SHAPE the evidence must take, and the shape is chosen so
 * the passivity trap cannot hide in it.
 *
 * ## The cell key is a 5-tuple PHANTOM INDEX (ADR-0041 §2)
 * Evidence keys on **instrumentClass × band × archetype-family × phase × role** (a {@link Cell}). It is
 * a *phantom index*: there is NO coercion between cells, ever. Pooling grades across `role` (the pod
 * {@link SeatId} axis) or across `phase` is ill-typed, because a pane's sign is role-dependent and
 * phase-dependent. This module provides no API that merges two cells' evidence — cross-cell coercion
 * has no surface, by construction. `role` is the closed {@link SeatId} roster (ADR-0041 A1); an unknown
 * role is REFUSED at deserialization (fail-closed — the ledger keys on the role, so it can never admit a
 * value outside the roster).
 *
 * ## The dual hash (ADR-0041 §2)
 * A template carries TWO hashes, bound by one rule: `templateHash` (sha256 of the canonical PRINTED
 * bytes) is the SKU — **identity** follows it; `elabHash` (sha256 of the ELABORATED/saturated form —
 * every defaulted slot explicitly printed) carries **evidence**. A catalog-default change can move a
 * template's denotation while its `templateHash` holds constant, but it changes the `elabHash`, so old
 * grades can never silently re-attach to a template whose meaning drifted underneath them. This module
 * takes both hashes as inputs (the printing lives in the renderer/lang, not here) — the skeleton records
 * them, it does not compute the printed forms.
 *
 * ## Grades are POLE-VECTORS, never sums (ADR-0041 §2)
 * The passivity trap is a *sign structure* — a restraint pane cuts the loss on the restraint pole while
 * forfeiting the gain on the action pole, and a scalar that netted the two would hide it. So a cell's
 * grade is a {@link PoleVector}: two separately-named sample vectors (restraint pole, action pole) with
 * **no `sum`/`net` field**, and this module offers **no function** that reduces a PoleVector to a
 * scalar. Scalar netting is inexpressible by construction, not by convention.
 *
 * Determinism: pure mapping + validation, no wall clock, no RNG (RUNTIME §0). Hashes are sha256 of
 * canonical bytes; `committedAt` is a caller-supplied logical stamp, never `Date.now()` inside here.
 */

import { canonicalizeJson } from "../canonical/json.ts";
import { isSeatId, type SeatId } from "./seat.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The cell key — the 5-tuple phantom index (ADR-0041 §2)
// ─────────────────────────────────────────────────────────────────────────────

/** The closed `instrumentClass` sort (ADR-0041 §2) — pinned NOW while the ledger is empty; opened only
 * by the sort-introduction ceremony (§3). Every founder record carries `equity-option` until a
 * non-equity cohort exists. */
export const INSTRUMENT_CLASSES = ["equity-option", "future", "perp", "fx-pair", "spot-equity"] as const;
export type InstrumentClass = (typeof INSTRUMENT_CLASSES)[number];

/** The closed `phase` axis (ADR-0041 §2) — orthogonal to band; a phase template is a named View
 * delivered by a Wake. */
export const LEDGER_PHASES = ["OPEN", "WAKE", "SHOCK", "CLOSE"] as const;
export type LedgerPhase = (typeof LEDGER_PHASES)[number];

/**
 * The evidence cell key — **instrumentClass × band × archetype-family × phase × role** (ADR-0041 §2).
 * `band` and `archetype-family` are open working-set strings (their taxonomy is a measurement question
 * for the first cohorts, ADR-0041 open questions #2), so they are typed as `string`; the two closed
 * sorts (`instrumentClass`, `phase`) and the closed `role` roster ({@link SeatId}) are the schema this
 * module enforces. Two cells are equal iff all five axes match — a phantom index, never coerced.
 */
export interface Cell {
  readonly instrumentClass: InstrumentClass;
  readonly band: string;
  readonly archetypeFamily: string;
  readonly phase: LedgerPhase;
  readonly role: SeatId;
}

/** The canonical string key of a {@link Cell} — a stable, order-fixed join over the five axes, used as
 * the ledger's per-cell map key. Because the axis order is fixed here in ONE place, two constructions of
 * the same cell always key identically, and two DIFFERENT cells never collide (the phantom index). Pure. */
export function cellKey(cell: Cell): string {
  // A NUL join over the fixed axis order — the axis strings can never contain NUL, so the key is
  // unambiguous (no separator injection), and the order is pinned here so it never drifts.
  return [cell.instrumentClass, cell.band, cell.archetypeFamily, cell.phase, cell.role].join(" ");
}

/** Fail-closed cell validation: every axis must be a member of its closed sort/roster (unknown role,
 * instrumentClass, or phase is REFUSED — never coerced into a neighbouring cell, ADR-0041 §2). `band`
 * and `archetype-family` must be non-empty strings (an empty axis is not a cell). Returns the narrowed
 * {@link Cell}. */
export function parseCell(raw: unknown): Cell {
  if (typeof raw !== "object" || raw === null) throw new Error("cell: not an object");
  const r = raw as Record<string, unknown>;
  if (!(INSTRUMENT_CLASSES as readonly string[]).includes(r.instrumentClass as string)) {
    throw new Error(`cell: unknown instrumentClass ${JSON.stringify(r.instrumentClass)} — closed sort: ${INSTRUMENT_CLASSES.join(", ")}`);
  }
  if (!(LEDGER_PHASES as readonly string[]).includes(r.phase as string)) {
    throw new Error(`cell: unknown phase ${JSON.stringify(r.phase)} — closed sort: ${LEDGER_PHASES.join(", ")}`);
  }
  if (!isSeatId(r.role)) {
    throw new Error(`cell: unknown role ${JSON.stringify(r.role)} — the closed pod-seat roster (ADR-0041 A1) refuses it; never coerced`);
  }
  if (typeof r.band !== "string" || r.band === "") throw new Error("cell: band must be a non-empty string");
  if (typeof r.archetypeFamily !== "string" || r.archetypeFamily === "") throw new Error("cell: archetypeFamily must be a non-empty string");
  return {
    instrumentClass: r.instrumentClass as InstrumentClass,
    band: r.band,
    archetypeFamily: r.archetypeFamily,
    phase: r.phase as LedgerPhase,
    role: r.role,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// TemplateRecord — the hypothesis carrying a ledger entry (ADR-0041 §2)
// ─────────────────────────────────────────────────────────────────────────────

/** The ledger tier of a template (ADR-0041 §2 / kestrel-wa0j.26). Every seed ENTERS as `tier-0` (a
 * hypothesis vs the required baseline pair); promotion toward a frozen default stays owner-gated on
 * realized-blotter evidence (ADR-0029 §q4) — this skeleton records the tier, it does not promote. */
export const LEDGER_TIERS = ["tier-0", "tier-1", "frozen-default"] as const;
export type LedgerTier = (typeof LEDGER_TIERS)[number];

/** Where a template came from + its re-seed lineage (ADR-0041 §2: reuse of a template in a NEW cell is
 * an explicit, lineage-recorded re-seed against a fresh null — never a silent coercion). `origin`
 * distinguishes a founder seed from an agent-authored lens from a re-seed; `parentTemplateHash` links a
 * re-seed to the template it descends from. */
export interface Lineage {
  readonly origin: "founder" | "agent-authored" | "reseed";
  readonly parentTemplateHash?: string;
  readonly note?: string;
}

const LINEAGE_ORIGINS = ["founder", "agent-authored", "reseed"] as const;

/**
 * A **TemplateRecord** (ADR-0041 §2): `{ templateHash, elabHash, cell, receipts, lineage, tier }`.
 * `templateHash` is the SKU (identity); `elabHash` carries evidence (denotation). `receipts` are the
 * content-addressed attribution references the template's builders carried (the skeleton stores them as
 * opaque strings — the receipt CONTENT is the renderer's concern). A template is a hypothesis; the
 * ledger is where it earns or loses its cell.
 */
export interface TemplateRecord {
  readonly templateHash: string;
  readonly elabHash: string;
  readonly cell: Cell;
  readonly receipts: readonly string[];
  readonly lineage: Lineage;
  readonly tier: LedgerTier;
}

/** A 64-hex sha256 string guard — both hashes and `predictionHash` must be sha256 hex, never a free
 * label (identity/evidence follow the hash, ADR-0041 §2). */
function isSha256Hex(v: unknown): v is string {
  return typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
}

/** Fail-closed {@link TemplateRecord} validation from untrusted JSON — every field checked, the cell
 * parsed through {@link parseCell} (so an unknown role refuses). Never coerces a malformed record onto a
 * passing shape. */
export function parseTemplateRecord(raw: unknown): TemplateRecord {
  if (typeof raw !== "object" || raw === null) throw new Error("TemplateRecord: not an object");
  const r = raw as Record<string, unknown>;
  if (!isSha256Hex(r.templateHash)) throw new Error("TemplateRecord: templateHash must be a sha256 hex string (the SKU)");
  if (!isSha256Hex(r.elabHash)) throw new Error("TemplateRecord: elabHash must be a sha256 hex string (the evidence key)");
  const cell = parseCell(r.cell);
  if (!Array.isArray(r.receipts) || !r.receipts.every((x) => typeof x === "string")) {
    throw new Error("TemplateRecord: receipts must be a string[]");
  }
  if (!(LEDGER_TIERS as readonly string[]).includes(r.tier as string)) {
    throw new Error(`TemplateRecord: unknown tier ${JSON.stringify(r.tier)} — closed: ${LEDGER_TIERS.join(", ")}`);
  }
  const lin = r.lineage;
  if (typeof lin !== "object" || lin === null) throw new Error("TemplateRecord: lineage must be an object");
  const l = lin as Record<string, unknown>;
  if (!(LINEAGE_ORIGINS as readonly string[]).includes(l.origin as string)) {
    throw new Error(`TemplateRecord: unknown lineage.origin ${JSON.stringify(l.origin)} — closed: ${LINEAGE_ORIGINS.join(", ")}`);
  }
  if (l.parentTemplateHash !== undefined && !isSha256Hex(l.parentTemplateHash)) {
    throw new Error("TemplateRecord: lineage.parentTemplateHash must be a sha256 hex string when present");
  }
  if (l.note !== undefined && typeof l.note !== "string") throw new Error("TemplateRecord: lineage.note must be a string when present");
  const lineage: Lineage = {
    origin: l.origin as Lineage["origin"],
    ...(l.parentTemplateHash !== undefined ? { parentTemplateHash: l.parentTemplateHash as string } : {}),
    ...(l.note !== undefined ? { note: l.note as string } : {}),
  };
  return { templateHash: r.templateHash, elabHash: r.elabHash, cell, receipts: [...r.receipts], lineage, tier: r.tier as LedgerTier };
}

/** Canonical JSON serialization (sorted keys, dropped undefined) — the byte-stable form the schema test
 * asserts and the store round-trips through. */
export function serializeTemplateRecord(record: TemplateRecord): string {
  return canonicalizeJson(record);
}

// ─────────────────────────────────────────────────────────────────────────────
// PredictionRecord — the pre-committed claim + its closed adjudication state machine (ADR-0041 §2)
// ─────────────────────────────────────────────────────────────────────────────

/** The closed adjudication states (ADR-0041 §2). The machine: COMMITTED → RUNNABLE | UNRUNNABLE;
 * RUNNABLE → GRADED; GRADED → CONFIRMED | REFUTED. `UNRUNNABLE` is an honorable TERMINAL state (the
 * theta pre-registration precedent), not a failure; `CONFIRMED`/`REFUTED` are terminal at identical
 * schema fidelity — refutations are recorded, there is no file drawer. */
export const ADJUDICATION_STATES = ["COMMITTED", "RUNNABLE", "UNRUNNABLE", "GRADED", "CONFIRMED", "REFUTED"] as const;
export type AdjudicationState = (typeof ADJUDICATION_STATES)[number];

/** The closed transition table: `state → the states it may advance to`. A terminal state maps to `[]`. */
const ADJUDICATION_TRANSITIONS: Readonly<Record<AdjudicationState, readonly AdjudicationState[]>> = {
  COMMITTED: ["RUNNABLE", "UNRUNNABLE"],
  RUNNABLE: ["GRADED"],
  GRADED: ["CONFIRMED", "REFUTED"],
  UNRUNNABLE: [],
  CONFIRMED: [],
  REFUTED: [],
};

/** Is `to` a legal next state from `from` under the closed adjudication machine? Pure. */
export function canAdjudicate(from: AdjudicationState, to: AdjudicationState): boolean {
  return ADJUDICATION_TRANSITIONS[from].includes(to);
}

/** Advance an adjudication along the closed machine, or THROW naming the illegal transition (fail-closed
 * — a prediction can never jump COMMITTED → CONFIRMED, skipping the runnable/graded firewall). Returns
 * the new state. */
export function advanceAdjudication(from: AdjudicationState, to: AdjudicationState): AdjudicationState {
  if (!canAdjudicate(from, to)) {
    const legal = ADJUDICATION_TRANSITIONS[from];
    throw new Error(
      `illegal adjudication transition ${from} → ${to}` +
        (legal.length === 0 ? ` (${from} is terminal)` : ` — from ${from} only: ${legal.join(", ")}`),
    );
  }
  return to;
}

/**
 * A **PredictionRecord** (ADR-0041 §2): `{ predictionHash, templateHash?, cell, claim, falsifier,
 * committedAt, adjudication }`. The `falsifier` is a predicate the merged harness can compute — an
 * experiment whose falsifier is prose is refused admission (the skeleton stores the falsifier as a
 * string IDENTIFIER/expression; the SKELETON does not evaluate it). `committedAt` is a caller-supplied
 * logical stamp (never a wall clock inside this module). Tier promotion later requires citing a
 * prediction whose hash predates the grading sample's draw — the two-stage forking-paths firewall — but
 * that gating is the harness's, not this skeleton's.
 */
export interface PredictionRecord {
  readonly predictionHash: string;
  readonly templateHash?: string;
  readonly cell: Cell;
  readonly claim: string;
  readonly falsifier: string;
  readonly committedAt: string;
  readonly adjudication: AdjudicationState;
}

/** Fail-closed {@link PredictionRecord} validation from untrusted JSON. The cell is parsed through
 * {@link parseCell} (unknown role refuses); the adjudication must be a member of the closed state set. */
export function parsePredictionRecord(raw: unknown): PredictionRecord {
  if (typeof raw !== "object" || raw === null) throw new Error("PredictionRecord: not an object");
  const r = raw as Record<string, unknown>;
  if (!isSha256Hex(r.predictionHash)) throw new Error("PredictionRecord: predictionHash must be a sha256 hex string");
  if (r.templateHash !== undefined && !isSha256Hex(r.templateHash)) {
    throw new Error("PredictionRecord: templateHash must be a sha256 hex string when present");
  }
  const cell = parseCell(r.cell);
  if (typeof r.claim !== "string" || r.claim === "") throw new Error("PredictionRecord: claim must be a non-empty string");
  if (typeof r.falsifier !== "string" || r.falsifier === "") {
    throw new Error("PredictionRecord: falsifier must be a non-empty computable-predicate string (prose is refused, ADR-0041 §2)");
  }
  if (typeof r.committedAt !== "string" || r.committedAt === "") throw new Error("PredictionRecord: committedAt must be a non-empty logical stamp");
  if (!(ADJUDICATION_STATES as readonly string[]).includes(r.adjudication as string)) {
    throw new Error(`PredictionRecord: unknown adjudication ${JSON.stringify(r.adjudication)} — closed: ${ADJUDICATION_STATES.join(", ")}`);
  }
  return {
    predictionHash: r.predictionHash,
    ...(r.templateHash !== undefined ? { templateHash: r.templateHash as string } : {}),
    cell,
    claim: r.claim,
    falsifier: r.falsifier,
    committedAt: r.committedAt,
    adjudication: r.adjudication as AdjudicationState,
  };
}

/** Canonical JSON serialization of a {@link PredictionRecord} (sorted keys, dropped undefined). */
export function serializePredictionRecord(record: PredictionRecord): string {
  return canonicalizeJson(record);
}

// ─────────────────────────────────────────────────────────────────────────────
// Grades — POLE-VECTORS per cell, never sums (ADR-0041 §2)
// ─────────────────────────────────────────────────────────────────────────────

/** The two poles of a matched set, named so a grade is always attributed to one of them (ADR-0041 §2 —
 * the null is that a pane HURTS until BOTH poles say otherwise). */
export type Pole = "restraint" | "action";

/**
 * A cell's grade evidence: two SEPARATELY-NAMED sample vectors (the `frozenPlanEv` draws on each pole),
 * with **NO `sum`/`net`/`mean` field** (ADR-0041 §2). The passivity trap is a sign structure — a
 * positive restraint entry netted against a negative action entry would look innocent as a scalar — so
 * the record shape is unable to hide it: there is no scalar, and this module offers no reduction of a
 * PoleVector to one. A reader that wants a pole-mean computes it OUTSIDE the ledger, per pole, on
 * purpose.
 */
export interface PoleVector {
  readonly restraint: readonly number[];
  readonly action: readonly number[];
}

/** An empty pole-vector — the identity a fresh cell starts from (both poles ungraded). */
export function emptyPoleVector(): PoleVector {
  return { restraint: [], action: [] };
}

/** Append ONE frozenPlanEv sample to ONE pole of a pole-vector, returning a NEW pole-vector (immutable).
 * The other pole is untouched — a sample is always attributed to exactly one pole, never smeared across
 * both, and never netted. Pure. */
export function appendPoleSample(pv: PoleVector, pole: Pole, sample: number): PoleVector {
  if (!Number.isFinite(sample)) throw new Error(`pole sample must be finite (got ${sample}) — never a fabricated grade`);
  return pole === "restraint"
    ? { restraint: [...pv.restraint, sample], action: pv.action }
    : { restraint: pv.restraint, action: [...pv.action, sample] };
}

// ─────────────────────────────────────────────────────────────────────────────
// The in-memory ledger store (skeleton — no grading logic)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The in-memory TemplateRecord ledger (ADR-0041 §2 skeleton): templates keyed by `templateHash`,
 * predictions keyed by `predictionHash`, and grade pole-vectors keyed by {@link cellKey}. It stores and
 * retrieves; it does NOT grade, promote, or net. The phantom-index no-coercion rule is structural:
 * there is no method that merges two cells' grades — grades are addressable only by an exact cell key,
 * so cross-cell coercion has no API surface at all.
 */
export class TemplateLedger {
  readonly #templates = new Map<string, TemplateRecord>();
  readonly #predictions = new Map<string, PredictionRecord>();
  readonly #grades = new Map<string, PoleVector>();

  /** Record (or replace) a template by its SKU. Re-recording the same `templateHash` is an idempotent
   * upsert — same identity, same slot. */
  putTemplate(record: TemplateRecord): void {
    this.#templates.set(record.templateHash, record);
  }

  /** The template for a SKU, or `undefined`. */
  template(templateHash: string): TemplateRecord | undefined {
    return this.#templates.get(templateHash);
  }

  /** Every template, insertion-ordered. */
  templates(): readonly TemplateRecord[] {
    return [...this.#templates.values()];
  }

  /** Record (or replace) a prediction by its hash. */
  putPrediction(record: PredictionRecord): void {
    this.#predictions.set(record.predictionHash, record);
  }

  /** The prediction for a hash, or `undefined`. */
  prediction(predictionHash: string): PredictionRecord | undefined {
    return this.#predictions.get(predictionHash);
  }

  /** Every prediction, insertion-ordered. */
  predictions(): readonly PredictionRecord[] {
    return [...this.#predictions.values()];
  }

  /** Append ONE frozenPlanEv sample to ONE pole of ONE cell's grade (ADR-0041 §2). A sample lands in
   * EXACTLY the addressed cell (`cellKey(cell)`) and EXACTLY the named pole — never coerced into a
   * neighbouring cell, never netted against the other pole. Fail-closed on a non-finite sample. */
  recordGrade(cell: Cell, pole: Pole, sample: number): void {
    const key = cellKey(cell);
    const current = this.#grades.get(key) ?? emptyPoleVector();
    this.#grades.set(key, appendPoleSample(current, pole, sample));
  }

  /** The pole-vector for an EXACT cell, or `undefined` when the cell is ungraded. There is NO variant
   * that reads across cells or nets the poles — the phantom index is total. */
  grade(cell: Cell): PoleVector | undefined {
    return this.#grades.get(cellKey(cell));
  }
}
