/**
 * # session/harness/file-handshake — the versioned, identity-stamped file-handshake Agent adapter (kestrel-m9i.7 stage-0)
 *
 * THE adapter every external CLI harness drives through. It satisfies the ONE ADR-0012 {@link Agent}
 * seam — `open(briefing) / decide(frame)` — over a **file handshake**: the runtime writes each
 * frozen-at-wake vantage to a run directory (`frame-<ord>.json` + a rendered `frame-<ord>.txt`), then
 * BLOCKS on a host wait until the external brain writes its reply (`turn-<ord>.json`, an
 * {@link AgentTurn} — or the bare `STAND_DOWN` sentinel). The de-facto v0 of this protocol drove three
 * models overnight through a Claude-Code subagent (scratchpad `sim-harness/PROTOCOL.md`); this module
 * PROMOTES it into a versioned seam with a stamped harness identity and a fail-closed parse identical
 * to `liveAgent`.
 *
 * ## Matched-interface discipline (kestrel-m9i.7 stage-0)
 * The frames written to the run dir are a PURE function of the graded bus (the engine renderer, never
 * the harness) — so `first-party-liveagent`, `claude-code`, `codex`, and `opencode` all see BYTE-
 * IDENTICAL frames for a given cell. The only degrees of freedom are the external brain and its
 * declared identity. Two adapter instances over the same bus therefore write byte-identical frames.
 *
 * ## Where the determinism boundary sits (ADR-0012 §3, ADR-0013, RUNTIME §0)
 * The file wait is a HOST wait — OFF the graded clock. Nothing on the deterministic record path touches
 * the wall clock or an RNG: the returned {@link AgentTurn} is the ONLY value that crosses into the graded
 * path, and its bytes are identical across reruns. A recorded run dir (turn files pre-present) replays
 * byte-identically: `open`/`decide` read the existing turn immediately, re-derive the same graded bus.
 *
 * ## Fail-closed — the three m9i outcomes, kept DISTINCT (identical to `liveAgent`)
 * - **authored** — a valid `turn-<ord>.json` (any validated {@link AgentTurn}, incl. an explicit
 *   `standDown` or the `STAND_DOWN` sentinel). Parsed through the SAME {@link parseTurn} the live agent
 *   uses — never repaired.
 * - **invalid** — a present-but-unusable turn (bad JSON / wrong shape / an invalid action) ⇒ a PASS
 *   whose JOURNAL names the defect (never a fabricated action, never a crash). `parseTurn`'s wording.
 * - **harness-timeout** — no `turn-<ord>.json` within the deterministic deadline ⇒ a PASS journalled
 *   `harness-timeout`. Distinct from `invalid` (a provider/brain failure vs an unusable reply), exactly
 *   as `liveAgent` keeps `provider-error` distinct.
 * None is a fabricated `standDown`: a missing/invalid turn de-arms NOTHING on its own — standing Plans
 * keep managing (an empty `actions[]` is a legitimate pass, ADR-0012 §3).
 *
 * ## Harness identity (System Profile v1.1 — mandatory-for-certified)
 * The adapter stamps its OWN transport identity `adapter = {@link FILE_HANDSHAKE_ADAPTER}` /
 * `adapterVersion = {@link FILE_HANDSHAKE_ADAPTER_VERSION}` (the shared seam — constant across every
 * external harness) AND the EXTERNALLY-DECLARED brain identity `harness` / `harnessVersion` onto the
 * advertised config. A caller-pinned value always WINS (never overwritten). `harness`/`harnessVersion`
 * are CFG-only (the a57.14 envelope stays exactly 14 fields) yet fold into the ConfigId — which the grid
 * CellKey keys on — so each harness is a DISTINCT grid column while `file-handshake` remains the one
 * transport all four brains share.
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

import { OPEN_ORDINAL, type ActingFrame, type Agent, type AgentConfig, type AgentTurn } from "../agent.ts";
import { renderBriefing, renderWakeDelta } from "../../frame/render.ts";
import type { BriefingInput, WakeDeltaInput } from "../../frame/types.ts";
import { parseTurn } from "./prompt.ts";

/** The transport identity this adapter stamps as `config.adapter` — the shared seam every external CLI
 * harness drives through (constant across `claude-code`/`codex`/`opencode`/`first-party-liveagent`). The
 * `harness` field carries the brain that pivots the tournament; THIS names the mechanism. */
export const FILE_HANDSHAKE_ADAPTER = "file-handshake";

/** The transport revision paired with {@link FILE_HANDSHAKE_ADAPTER}. Bump on any behavioral change to
 * this adapter (frame serialization, parse, timeout policy) — it folds into the ConfigId, so a bump mints
 * a new grid column rather than contaminating an old one (ADR-0013 (d)). `v2`: the default
 * `frame-<ord>.txt` companion is the canonical `src/frame` Rendering, not a JSON dump (kestrel-wa0j.2) —
 * a change to the bytes an external brain perceives, so it must mint a new column. */
export const FILE_HANDSHAKE_ADAPTER_VERSION = "v2";

/** The bare sentinel a harness may write to `turn-<ord>.json` instead of a JSON turn to de-arm cleanly.
 * An AUTHORED outcome (the brain chose to step aside), kept distinct from an `invalid`/unparseable reply. */
export const STAND_DOWN_SENTINEL = "STAND_DOWN";

/** The default deterministic deadline (5 min) after which a missing turn fails closed to a
 * `harness-timeout` pass. Off the graded clock — it only bounds the host wait. */
export const DEFAULT_TIMEOUT_MS = 300_000;

/** The classification of one handshake turn — the m9i three-way distinction kept explicit on the capture
 * record so a leaderboard never conflates an authored reply, an unusable one, and a brain that never
 * answered (ADR-0013 (a)). Mirrors `liveAgent`'s {@link import("./live-agent.ts").TurnOutcome} with
 * `provider-error` specialized to `harness-timeout` (the file-handshake failure mode). */
export type HandshakeOutcome = "authored" | "invalid" | "harness-timeout";

/** One frame written to the run dir: the machine envelope `{ kind, ordinal, frame }` serialized to
 * `frame-<ord>.json`, and rendered to a human-readable `frame-<ord>.txt` companion. */
export interface FrameEnvelope {
  readonly kind: "open" | "wake";
  /** `open` for the briefing, else the 4-digit wake ordinal (`0000`, `0001`, …). */
  readonly ordinal: string;
  readonly frame: unknown;
}

/** Per-turn handshake evidence — recorded OFF the graded path (never a graded input). Keeps the three
 * outcomes distinct and points at the exact frame/turn files, so an audit can replay a cell by hand. */
export interface HandshakeCapture {
  /** {@link OPEN_ORDINAL} for the open turn, else the 0-based wake ordinal. */
  readonly ordinal: number;
  /** The `<ord>` label used in the file names (`open` | `0000` | …). */
  readonly ordLabel: string;
  readonly outcome: HandshakeOutcome;
  /** Present for `invalid` / `harness-timeout` — the fail-closed reason journalled onto the pass. */
  readonly reason?: string;
  readonly framePath: string;
  readonly turnPath: string;
}

/** Options for {@link fileHandshakeAgent}. `harness`/`harnessVersion` are the externally-declared brain
 * identity (the tournament pivot); `now`/`sleep` are injectable so the host wait — and the timeout — are
 * deterministic in tests without a real delay; `render` supplies the rendered-text companion (defaults to
 * a pure, matched-interface renderer); `capture` is a sink one record is appended to per turn. */
export interface FileHandshakeOptions {
  /** The run directory frames/turns live in. Created if absent. Kept OUT of the graded record path. */
  readonly dir: string;
  /** The externally-declared brain identity, e.g. `claude-code/2.x` (kestrel-m9i.7). A caller-pinned
   * `config.harness` wins over this. */
  readonly harness: string;
  /** The brain version/revision token, paired with {@link harness}. A caller-pinned `config.harnessVersion`
   * wins over this. */
  readonly harnessVersion: string;
  /** The deterministic deadline in ms; a missing turn past it fails closed to `harness-timeout`. */
  readonly timeoutMs?: number;
  /** The host-wait poll interval in ms (default 100). Off the graded clock. */
  readonly pollMs?: number;
  /** Injectable clock for the host wait/deadline (default `Date.now`) — off the graded path. */
  readonly now?: () => number;
  /** Injectable sleep for the poll loop (default `Bun.sleep`) — off the graded path. */
  readonly sleep?: (ms: number) => Promise<void>;
  /** The rendered-text companion renderer — a PURE function of the frame (matched-interface: same bus ⇒
   * same text regardless of harness). Defaults to {@link renderFrameText}. */
  readonly render?: (env: FrameEnvelope) => string;
  /** A sink array one {@link HandshakeCapture} is appended to per turn (open + each decide). */
  readonly capture?: HandshakeCapture[];
}

// ─────────────────────────────────────────────────────────────────────────────
// Pure core (no I/O, no clock) — the byte-stable frame/turn contract
// ─────────────────────────────────────────────────────────────────────────────

/** The `<ord>` label for a wake ordinal: `open` for the OPEN turn ({@link OPEN_ORDINAL}), else the
 * 0-based wake ordinal zero-padded to 4 digits (`0000`, `0001`, …) — the PROTOCOL.md convention. Pure. */
export function ordLabelOf(ordinal: number): string {
  return ordinal === OPEN_ORDINAL ? "open" : String(ordinal).padStart(4, "0");
}

/** Serialize a {@link FrameEnvelope} to the byte-stable `frame-<ord>.json` text — deterministic 2-space
 * JSON (a pure function of the frame, so two adapter instances over one bus write identical bytes). */
export function serializeFrameEnvelope(env: FrameEnvelope): string {
  return JSON.stringify(env, null, 2);
}

/** The DEFAULT rendered-text companion for a frame — THE canonical `src/frame` Rendering
 * ({@link renderBriefing} for the open envelope, {@link renderWakeDelta} for a wake — an
 * {@link ActingFrame} extends {@link WakeDeltaInput}), plus a reply-instruction TRAILER (protocol
 * scaffolding, after the frame so the kernel still LEADS). It replaces the pre-wa0j.2 JSON dump: the
 * external brain now perceives the SAME screen an in-process agent does; the machine twin stays
 * `frame-<ord>.json`. PURE and matched-interface (a function of the frame only, never the harness), so
 * every brain sees the same `frame-<ord>.txt`. The `env.kind` discriminant selects the renderer; the
 * frame is the driver's own typed value (this module never parses one from disk), so no runtime shape
 * re-validation happens here. A caller may still inject its own renderer via `opts.render`. */
export function renderFrameText(env: FrameEnvelope): string {
  // A wake frame that carries a delivered View (kestrel-wa0j.4 — a scheduled wake's stored View,
  // already resolved fail-closed by the driver) renders under THAT selection, exactly as the
  // in-process live agent does; absent ⇒ the default WAKE panes, byte-identical to before.
  const wakeView = env.kind === "wake" ? (env.frame as ActingFrame).view : undefined;
  const { rendered, refusal } = renderFrameBody(env, wakeView);
  return [
    ...(refusal !== undefined ? [refusal, ""] : []),
    rendered,
    "",
    `→ you are the author (the trader): decide on this frozen, date-blind vantage and write turn-${env.ordinal}.json (an AgentTurn, or the bare ${STAND_DOWN_SENTINEL} sentinel).`,
    "",
  ].join("\n");
}

/** Render the frame body under its delivered View, FAIL-CLOSED to the default panes on a materialization
 * refusal (kestrel-wa0j.19 §1 — the BLOCKER). The file handshake writes this text to disk for an external
 * brain; a delivered View that resolves but cannot materialize (a window arg the frame can't serve, an
 * over-budget View) must never crash the frame-text write. On a throw we re-render WITHOUT the View (the
 * default WAKE panes — known-good) and return a `refusal` note the caller LEADS the frame with (surfaced,
 * never silent). A throw with NO View to blame is a genuine render defect and is re-thrown. Pure. */
function renderFrameBody(env: FrameEnvelope, wakeView: ActingFrame["view"]): { readonly rendered: string; readonly refusal?: string } {
  if (env.kind === "open") return { rendered: renderBriefing(env.frame as BriefingInput) };
  try {
    return { rendered: renderWakeDelta(env.frame as WakeDeltaInput, wakeView !== undefined ? { view: wakeView } : {}) };
  } catch (e) {
    if (wakeView === undefined) throw e; // no View to blame — a genuine render defect, not agent-authored input
    return {
      rendered: renderWakeDelta(env.frame as WakeDeltaInput, {}),
      refusal: `⚠ harness: delivered View "${wakeView.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; showing the default WAKE panes (fail-closed, kestrel-wa0j.19).`,
    };
  }
}

/** The fail-closed `harness-timeout` turn: a PASS whose JOURNAL names the missed deadline (never a
 * fabricated standDown — standing Plans keep managing). Pure. */
export function timeoutTurn(ordLabel: string, timeoutMs: number): { readonly turn: AgentTurn; readonly reason: string } {
  const reason = `harness-timeout — no turn-${ordLabel}.json within ${timeoutMs / 1000}s`;
  return { turn: { actions: [], journal: `harness: ${reason}` }, reason };
}

/**
 * Parse one turn-file's raw text into a validated {@link AgentTurn} with its {@link HandshakeOutcome} —
 * the fail-closed core, IDENTICAL to `liveAgent` (it delegates to the same {@link parseTurn}), never
 * repaired. The bare {@link STAND_DOWN_SENTINEL} is intercepted first as an AUTHORED clean de-arm (so a
 * brain can step aside without composing JSON); anything else flows through `parseTurn` — `ok` ⇒
 * `authored`, otherwise `invalid` carrying `parseTurn`'s reason on a PASS + journal. Pure: no I/O.
 */
export function parseHandshakeTurn(text: string): { readonly turn: AgentTurn; readonly outcome: HandshakeOutcome; readonly reason?: string } {
  if (text.trim() === STAND_DOWN_SENTINEL) {
    return { turn: { actions: [{ kind: "standDown", reason: `harness: ${STAND_DOWN_SENTINEL}` }] }, outcome: "authored" };
  }
  const parsed = parseTurn(text);
  if (parsed.ok) return { turn: parsed.turn, outcome: "authored" };
  return { turn: parsed.turn, outcome: "invalid", ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) };
}

/** The identity-stamped config the adapter advertises: the caller's config with the transport identity
 * ({@link FILE_HANDSHAKE_ADAPTER}/{@link FILE_HANDSHAKE_ADAPTER_VERSION}) and the externally-declared
 * `harness`/`harnessVersion` folded in — a caller-pinned value always wins. PURE (no I/O), so a driver can
 * derive a ConfigId for a cell WITHOUT opening the run dir. */
export function stampHarnessIdentity(config: AgentConfig, harness: string, harnessVersion: string): AgentConfig {
  return {
    ...config,
    adapter: config.adapter ?? FILE_HANDSHAKE_ADAPTER,
    adapterVersion: config.adapterVersion ?? FILE_HANDSHAKE_ADAPTER_VERSION,
    harness: config.harness ?? harness,
    harnessVersion: config.harnessVersion ?? harnessVersion,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The adapter — the ADR-0012 Agent seam over the file handshake
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Build the file-handshake {@link Agent} over a run directory. `open`/`decide` write the frame
 * (`frame-<ord>.json` + `frame-<ord>.txt`) then block on a host wait until `turn-<ord>.json` lands (or
 * the deadline passes). The returned {@link AgentTurn} is the only value crossing the determinism
 * boundary; the wait, the clock, and the files sit entirely OFF the graded path. Fail-closed exactly like
 * `liveAgent` (invalid ⇒ pass + journal; missing ⇒ `harness-timeout` pass + journal). The advertised
 * `config` carries the stamped harness identity ({@link stampHarnessIdentity}).
 */
export function fileHandshakeAgent(config: AgentConfig, opts: FileHandshakeOptions): Agent {
  const dir = opts.dir;
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const pollMs = opts.pollMs ?? 100;
  const now = opts.now ?? Date.now;
  const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
  const render = opts.render ?? renderFrameText;
  const fullConfig = stampHarnessIdentity(config, opts.harness, opts.harnessVersion);

  mkdirSync(dir, { recursive: true });

  const awaitTurn = async (kind: "open" | "wake", ordinal: number, frame: unknown): Promise<AgentTurn> => {
    const ordLabel = ordLabelOf(ordinal);
    const framePath = join(dir, `frame-${ordLabel}.json`);
    const textPath = join(dir, `frame-${ordLabel}.txt`);
    const turnPath = join(dir, `turn-${ordLabel}.json`);
    const env: FrameEnvelope = { kind, ordinal: ordLabel, frame };
    // Matched-interface: the frame bytes are a pure function of the bus, never of the harness.
    writeFileSync(framePath, serializeFrameEnvelope(env));
    writeFileSync(textPath, render(env));

    const deadline = now() + timeoutMs;
    while (!existsSync(turnPath)) {
      if (now() >= deadline) {
        const { turn, reason } = timeoutTurn(ordLabel, timeoutMs);
        opts.capture?.push({ ordinal, ordLabel, outcome: "harness-timeout", reason, framePath, turnPath });
        return turn;
      }
      await sleep(pollMs);
    }

    let text: string;
    try {
      text = readFileSync(turnPath, "utf8");
    } catch (e) {
      // A present-but-unreadable turn is an unusable reply — fail closed to an invalid PASS (not a crash).
      const reason = `unreadable turn-${ordLabel}.json — ${e instanceof Error ? e.message : String(e)}`;
      opts.capture?.push({ ordinal, ordLabel, outcome: "invalid", reason, framePath, turnPath });
      return { actions: [], journal: `harness: ${reason}` };
    }
    const parsed = parseHandshakeTurn(text);
    opts.capture?.push({
      ordinal,
      ordLabel,
      outcome: parsed.outcome,
      ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}),
      framePath,
      turnPath,
    });
    return parsed.turn;
  };

  return {
    config: fullConfig,
    open(briefing: BriefingInput): Promise<AgentTurn> {
      return awaitTurn("open", OPEN_ORDINAL, briefing);
    },
    decide(frame: ActingFrame): Promise<AgentTurn> {
      return awaitTurn("wake", frame.wakeOrdinal, frame);
    },
  };
}
