/**
 * # session/armed-plan-terms — capture the ARMED plans' enforced terms for the watcher (kestrel-wa0j.29)
 *
 * The watcher is asked to judge a plan's PREMISE ("the plan armed on a thesis — is it still true?") yet
 * it NEVER sees the armed document's text: the WHEN/DO/TP/EXIT/INVALIDATE clauses live in the supersede
 * action on the bus and are never rendered into any frame — the kernel carries only the plan's lifecycle
 * NAME + STATE (frame/types.ts PlanStateEntry). This module closes that gap by capturing, from the armed
 * document ASTs the driver ALREADY holds, the TYPED terms the `armed-plan` pane renders.
 *
 * ## Attribution honesty (ADR-0041 §1: a pane invents no value)
 * Every term is a TYPED value from the plan AST printed through the SAME canonical printer the
 * round-trip is defined by ({@link ../lang/print.ts planClause}/{@link ../lang/print.ts printTrigger}/
 * {@link ../lang/print.ts budgetStr}) — a typed value → its byte-stable text, NEVER a hand-rolled
 * restatement, never the rendered pane bytes. The data enters the frame THROUGH the frame input, exactly
 * as the `delta`/`prior-session` panes' data does.
 *
 * ## Which plans (absent-not-hidden, ADR-0041 §3)
 * ONLY plans in an ENFORCED lifecycle state — `armed` / `fired` / `managing` — carry terms the watcher
 * is actively enforcing; an `authored` (not yet armed) or `done` plan is excluded. An empty enforced set
 * is threaded as ABSENT ({@link armedPlanTermsOf} returns `undefined`), never an empty list — so the pane
 * fails closed to exactly one absent-with-reason line, byte-identical to a frame without the field.
 *
 * Pure: no clock, no RNG. A same-name replacement's LATEST armed AST wins (the driver's arm order).
 */

import type { ArmedPlanTerms, ArmedPlanTermsEntry } from "../frame/types.ts";
import type { ExpirySelector, Leg, PlanClause, PlanStatement } from "../lang/index.ts";
import { budgetStr, planClause, printTrigger } from "../lang/index.ts";

/** The plan lifecycle states in which the watcher is ENFORCING the plan's terms (kestrel-wa0j.29) — the
 * plan has committed (or is committing) capital and the WHEN/TP/EXIT/INVALIDATE are live. An `authored`
 * plan (awaiting its arm gate) and a `done` plan (terminal) carry no terms the watcher enforces NOW. */
export const ENFORCED_PLAN_STATES: ReadonlySet<string> = new Set(["armed", "fired", "managing"]);

/**
 * Calendar days from the tape's `session_date` to an authored per-leg expiry DATE (kestrel-wa0j.74) —
 * the SAME days-to-expiry the ih5h date-blind chain layer serves as `+Nd`
 * ({@link ../session/simulate.ts dateBlindOptions}), computed here from the session date the grammar's
 * `dte` selector is itself defined against ("`0dte` means expiring on the session's date",
 * {@link ../engine/plans.ts expiryAdmits}). Both dates are ISO `YYYY-MM-DD`; the difference is a
 * whole-day count taken at UTC midnight, so it is DST-free and deterministic (no wall clock).
 */
function dteFromSessionDate(sessionDate: string, expiryDate: string): number {
  const utcMidnight = (d: string): number => {
    const [y, m, day] = d.split("-").map((s) => Number.parseInt(s, 10));
    return Date.UTC(y ?? 0, (m ?? 1) - 1, day ?? 1);
  };
  return Math.round((utcMidnight(expiryDate) - utcMidnight(sessionDate)) / 86_400_000);
}

/**
 * Date-blind ONE expiry selector for the armed-plan pane (kestrel-wa0j.74). A relative `expiry-dte`
 * (`0dte`) or a `expiry-tag` (`weekly`) is ALREADY date-blind and passes through untouched; an ABSOLUTE
 * `expiry-date` (`2026-08-21`, authorable since kestrel-ih5h seam 1) is normalized to its DTE-relative
 * `expiry-dte` equivalent against the session date. The canonical printer then emits the grammar's OWN
 * `<n>dte` form, so NO iso-date token crosses the agent boundary and the {@link
 * ../session/simulate.ts assertFrameDateBlind} fence stays green. print.ts is untouched — the authored
 * document still prints its real date (round-trip stability, ADR-0004); only this FRAME capture blinds it.
 */
function dateBlindExpiry(e: ExpirySelector, sessionDate: string): ExpirySelector {
  return e.kind === "expiry-date" ? { kind: "expiry-dte", dte: dteFromSessionDate(sessionDate, e.date) } : e;
}

/** Date-blind an order leg's per-leg tenor. Only an OPTION leg (`kind: "leg"`) carries an `exp`; an
 * equity leg and an expiry-less option leg pass through unchanged. */
function dateBlindLeg(l: Leg, sessionDate: string): Leg {
  return l.kind === "leg" && l.expiry !== undefined ? { ...l, expiry: dateBlindExpiry(l.expiry, sessionDate) } : l;
}

/** Date-blind the per-leg tenors of a clause before it is printed into the pane. Only `do`/`also`/`reload`
 * carry legs (the entry tickets); every other clause passes through unchanged. */
function dateBlindClause(c: PlanClause, sessionDate: string): PlanClause {
  if (c.kind === "do" || c.kind === "also" || c.kind === "reload") {
    return { ...c, legs: c.legs.map((l) => dateBlindLeg(l, sessionDate)) };
  }
  return c;
}

/** The ENTRY tickets — what the plan acquires/reloads. */
function isEntry(c: PlanClause): boolean {
  return c.kind === "do" || c.kind === "also" || c.kind === "reload";
}
/** The EXIT surface — take-profits + thesis-break stops the watcher enforces to get OUT. */
function isExit(c: PlanClause): boolean {
  return c.kind === "tp" || c.kind === "exit";
}
/** The INVALIDATION surface — thesis-dead + resting-cancels that kill the thesis. */
function isInvalidation(c: PlanClause): boolean {
  return c.kind === "invalidate" || c.kind === "cancel-if";
}

/** Capture ONE armed plan's enforced terms as a typed {@link ArmedPlanTermsEntry}: the arming premise
 * (top-level `WHEN`), the entry/exit/invalidation clause runs, and the size envelope (`budget`) — each
 * printed through the canonical printer, each optional-honest (an absent field is a clause the plan does
 * not have, NEVER a fabricated one). Per-leg expiries are date-blinded against `sessionDate` FIRST
 * ({@link dateBlindClause}, kestrel-wa0j.74) so an absolute `exp 2026-08-21` renders as a relative
 * `<n>dte` and no iso-date crosses the agent boundary. Pure. */
export function armedPlanEntryOf(p: PlanStatement, sessionDate: string): ArmedPlanTermsEntry {
  const when = p.when !== undefined ? printTrigger(p.when) : undefined;
  const entries = p.clauses.filter(isEntry).map((c) => planClause(dateBlindClause(c, sessionDate)));
  const exits = p.clauses.filter(isExit).map((c) => planClause(dateBlindClause(c, sessionDate)));
  const invalidations = p.clauses.filter(isInvalidation).map((c) => planClause(dateBlindClause(c, sessionDate)));
  const sizeEnvelope = p.budget !== undefined ? budgetStr(p.budget) : undefined;
  return {
    name: p.name,
    ...(when !== undefined ? { when } : {}),
    ...(entries.length > 0 ? { entries } : {}),
    ...(exits.length > 0 ? { exits } : {}),
    ...(invalidations.length > 0 ? { invalidations } : {}),
    ...(sizeEnvelope !== undefined ? { sizeEnvelope } : {}),
  };
}

/**
 * Capture the ARMED plans' enforced terms from the armed document ASTs, filtered to the plans whose
 * CURRENT lifecycle state (per the frame's kernel PlanStateEntry set) is enforced — or `undefined` when
 * NONE is enforced (absent-not-hidden: the pane then fails closed to absent-with-reason, and a frame
 * without the field is byte-identical). Preserves the driver's arm order; a same-name replacement's
 * LATEST AST wins (the last-armed document is the one whose terms are live). Pure.
 *
 * @param asts          every armed plan statement the driver holds, in arm order.
 * @param enforcedNames the plan NAMES currently in an enforced lifecycle state (from the kernel plan set).
 * @param sessionDate   the tape's `session_date` — the reference an absolute per-leg `exp` is blinded to
 *                      a relative `<n>dte` against (kestrel-wa0j.74), so no iso-date reaches the frame.
 */
export function armedPlanTermsOf(
  asts: readonly PlanStatement[],
  enforcedNames: ReadonlySet<string>,
  sessionDate: string,
): ArmedPlanTerms | undefined {
  // Latest-armed AST per name (a legal same-name replacement supersedes the prior terms).
  const latestByName = new Map<string, PlanStatement>();
  for (const p of asts) latestByName.set(p.name, p);
  const plans: ArmedPlanTermsEntry[] = [];
  const seen = new Set<string>();
  for (const p of asts) {
    if (seen.has(p.name)) continue; // one entry per name, in first-armed order (value is the latest AST)
    if (!enforcedNames.has(p.name)) continue; // only plans the watcher is actively enforcing
    seen.add(p.name);
    plans.push(armedPlanEntryOf(latestByName.get(p.name)!, sessionDate));
  }
  if (plans.length === 0) return undefined; // absent-not-hidden — never an empty list
  return { plans };
}
