import { withLock } from "std::concurrency"
import { keys } from "std::object"
import { dirname, basename } from "std::path"
import { exists } from "std::shell"
import { highlight, diff } from "std::syntax"
import { ChoiceItem } from "std::ui"
import { interruptChoice } from "std::ui/cli"
import { render, text } from "std::ui/layout"
import { table } from "std::ui/table"

import {
  _checkPolicy,
  _validatePolicy,
  _writePolicyFile,
  _builtinPolicy,
  _builtinPolicyNames,
  _BUILTIN_POLICIES,
  _minimalAutoApprovePolicy,
  _recommendedAutoApprovePolicy,
  _withWritesPolicy,
  _approveAllPolicy,
 } from "agency-lang/stdlib-lib/policy.js"


/** @module
  @summary Decide whether to approve or reject the interrupts an agent raises, without prompting the user every time.
  Decide whether to approve or reject the interrupts an agent raises,
  without prompting the user every time. A `Policy` is an ordered list of
  glob-pattern rules per interrupt effect, evaluated first-match-wins.

  `cliPolicyHandler` is the common entry point: it loads a policy file,
  prompts the user on each new interrupt, remembers "always" decisions, and
  replays matching rules so each pattern is only asked about once.

  ```ts
  import { cliPolicyHandler } from "std::policy"

  node main() {
    // Bind to a variable — the `with` clause only accepts an identifier.
    const handler = cliPolicyHandler(
      file: "${env("HOME")}/.myapp/policy.json",
      fields: { "std::read": [{ field: "dir", matchSubpaths: true }] },
    )
    handle {
      llm("hi", { tools: [...] })
    } with handler
  }
  ```

  For a different UI (a web prompt, a Slack bot, a non-interactive CI mode),
  build your own handler on the pure primitives: `checkPolicy`, `recordRule`,
  `recordScopedRule`, `parsePolicyFile`, `writePolicyFile`, `validatePolicy`,
  and `buildScopedMatch`.
*/

/** Key of an interrupt's `data` object (e.g. `"dir"`, `"command"`). */
export type InterruptDataKey = string

/**
 * Glob pattern used to match an interrupt-data value. Patterns are
 * picomatch globs. They support `*`, `**`, and brace-expansion like
 * `{a,b}` for unions. A literal string with no glob metacharacters
 * matches only that exact value.
 */
export type InterruptDataVal = string

/**
 * Identifier for an interrupt's effect (e.g. `"std::read"`,
 * `"myapp::deploy"`).
 */
export type InterruptEffect = string

/**
 * One row of a `Policy`. A rule passes if every key in `match` is
 * present in the interrupt's `data` and its value matches the glob
 * pattern. Omit `match` (or set it to `{}`) for a catch-all that
 * applies to every interrupt of the parent effect.
 */
export type PolicyRule = {
  match?: Record<InterruptDataKey, InterruptDataVal>;
  action: "approve" | "reject" | "propagate"
}

/**
 * A policy: ordered rules per interrupt effect. `checkPolicy` walks
 * the array for `intr.effect` in order and returns on the first
 * matching rule. If no rule for the effect exists, evaluation falls
 * through to `propagate` (i.e. ask the next handler in the chain).
 */
export type Policy = Record<InterruptEffect, PolicyRule[]>

/**
 * Built-in policies, re-exported from the runtime single source of truth
 * (`lib/runtime/builtinPolicies.ts`) so the agent and other Agency code share
 * exactly what `agency run --policy <name>` uses.
 *
 * - `minimalAutoApprovePolicy` — memory + the safe read-only agency subcommands.
 * - `recommendedAutoApprovePolicy` — reads and web/search; writes/shell/git prompt.
 * - `approveAllPolicy` — approve everything (disposable sandboxes only).
 * - `withWritesPolicy(baseDir)` — recommended + writes/git scoped to `baseDir`.
 * - `builtinPolicy(name, baseDir)` — resolve a name to a `Policy` (null if unknown).
 * - `builtinPolicyNames()` / `BUILTIN_POLICIES` — the names and their descriptions.
 */
export static const minimalAutoApprovePolicy = _minimalAutoApprovePolicy
export static const recommendedAutoApprovePolicy = _recommendedAutoApprovePolicy
export static const approveAllPolicy = _approveAllPolicy
export static const BUILTIN_POLICIES = _BUILTIN_POLICIES

export def withWritesPolicy(baseDir: string) {
  """The recommended policy plus file-system and git-write effects, each
  scoped to `baseDir` and its children."""
  return _withWritesPolicy(baseDir)
}

export def builtinPolicy(name: string, baseDir: string): Policy | null {
  """Resolve a built-in policy name to a concrete Policy, scoping
  cwd-relative variants to `baseDir`. Returns null for an unknown name."""
  return _builtinPolicy(name, baseDir)
}

export def builtinPolicyNames(): string[] {
  """The names accepted by builtinPolicy, e.g. for an approval prompt."""
  return _builtinPolicyNames()
}

/**
 * The five answers `cliPolicyHandler`'s prompt accepts:
 * - `"approve"` / `"reject"` — one-off (a) / (r).
 * - `"approve-always"` / `"reject-always"` — (aa) / (rr). Records a
 *   catch-all rule for the effect, so future interrupts of this effect
 *   resolve without prompting.
 * - `"approve-always-here"` — (ap). Records a scoped rule pinned to
 *   whichever fields you listed in `ScopedRuleFields` for this effect.
 *   Only offered when the effect has an entry in the config.
 */
export type Decision =
  | "approve"
  | "reject"
  | "approve-always"
  | "approve-always-here"
  | "reject-always"

/**
 * One column in a `ScopedRuleFields` entry — names an interrupt-data
 * field that the "approve-always-here" rule should pin.
 *
 * - `field` — the key in `intr.data` to pin (e.g. `"dir"`).
 * - `matchSubpaths` — when `true`, brace-expand the value so the
 *   rule matches both the exact value AND any nested path under it.
 *   Pass `true` for directory-like fields (so approving `/tmp/x`
 *   also approves `/tmp/x/sub/file.txt`). Pass `false` for opaque
 *   identifiers (commands, IDs, env names) that shouldn't fan out.
 */
export type ScopedField = {
  field: string;
  matchSubpaths: boolean
}

/**
 * Per-effect configuration consumed by `buildScopedMatch` and the
 * `cliPolicyHandler`. Maps each interrupt effect to the fields its
 * "approve-always-here" rule should pin. Effects not present in this
 * map don't offer the (ap) prompt option. The user falls back to
 * (a) / (r) / (aa) / (rr).
 *
 * Example:
 * ```ts
 * const FIELDS: ScopedRuleFields = {
 *   "std::read":  [{ field: "dir", matchSubpaths: true }],
 *   "std::exec":  [
 *     { field: "command",    matchSubpaths: false },
 *     { field: "subcommand", matchSubpaths: false },
 *   ],
 * }
 * ```
 */
export type ScopedRuleFields = Record<InterruptEffect, ScopedField[]>

type CliPolicyOpts = {
  file: string;
  fields: ScopedRuleFields
}

// Internal state for `cliPolicyHandler`. Singleton. Calling
// `cliPolicyHandler` more than once silently overwrites these.
let globalCliPolicyOpts: CliPolicyOpts = {
  file: "",
  fields: {}
}
let _policy: Policy | null = null
let _policyLoaded: boolean = false
let _pendingSave: boolean = false
// True only while the handler is reading or writing its OWN policy file
// (inside maybeLoadPolicy / maybeFlush / flushPolicy). Those file ops
// raise std::read / std::write interrupts that re-enter this same
// handler. We approve them directly (see `_handler`). Otherwise the
// `_policy == null` default-reject would veto the `with approve` on the
// file op (any handler can veto an interrupt), the load/save would fail,
// and every real interrupt would fall through to prompting.
let _internalIo: boolean = false

/**
 * Evaluate a policy against a single interrupt. Returns the result
 * of `approve()`, `reject()`, or `propagate()` corresponding to the
 * first matching rule for `interrupt.effect`. If no rule matches
 * (no rules for the effect, or every rule's `match` failed), returns
 * `propagate()` so the next handler in the chain runs.
 *
 * Designed for use inside a custom handler. The CLI sugar
 * (`cliPolicyHandler`) calls this for you.
 */
export def checkPolicy(
  policy: Record<string, any>,
  interrupt: Record<string, any>,
) {
  """
  Evaluate a policy against an interrupt. Returns approve(), reject(), or propagate() based on the first matching rule.

  @param policy - Ordered rules keyed by interrupt effect; each rule has optional glob-pattern match fields and an action.
  @param interrupt - The interrupt to evaluate.
  """
  return _checkPolicy(policy, interrupt)
}

/**
 * Check that a `Policy` is structurally valid (every entry is an
 * array of `PolicyRule` with a recognised `action`, every `match`
 * is a flat string→string map, etc.). Returns
 * `{ success: true }` or `{ success: false, error: string }`.
 *
 * Call before persisting user-supplied or hand-edited policy data;
 * `writePolicyFile` calls this internally before writing.
 */
export def validatePolicy(policy: Record<string, any>): Result<void> {
  """
  Validate that a policy object is well-formed. Returns { success: true } if valid, or { success: false, error } describing the problem.

  @param policy - The policy object to validate.
  """
  return _validatePolicy(policy)
}

/**
 * Build the `match` map for a scoped rule by reading the configured
 * fields out of `intr.data`. The returned object is shaped to plug
 * straight into a `PolicyRule.match`:
 *
 * ```ts
 * const match = buildScopedMatch(intr, fields)
 * const rule: PolicyRule = { match: match, action: "approve" }
 * ```
 *
 * For each `ScopedField` configured for `intr.effect`:
 * - The field's value is read from `intr.data`.
 * - If `matchSubpaths: true`, the value is wrapped as
 *   `"{value,value/**}"` so the resulting glob matches both the
 *   exact value and any subpath under it.
 * - If `matchSubpaths: false`, the value is used as-is (literal
 *   match).
 *
 * Fields that are absent from `intr.data` (`null` / `undefined`)
 * are skipped silently. Effects not present in `fields` return `{}`.
 *
 * Most callers should use `recordScopedRule` instead, which calls
 * this internally. `buildScopedMatch` is exposed for callers
 * assembling rules by hand or implementing a custom UI that needs
 * to preview the match before recording.
 */
export def buildScopedMatch(
  intr: Record<string, any>,
  fields: ScopedRuleFields,
): Record<string, string> {
  """
  Build a match object for an interrupt, pinned to the configured fields. Returns {} when the effect has no configured fields.

  @param intr - The interrupt whose data fields to pin.
  @param fields - Per-effect config naming which data fields to pin.
  """
  let specs: ScopedField[] = []
  if (fields[intr.effect] != undefined) {
    specs = fields[intr.effect]
  }
  let match: Record<string, string> = {}
  for (spec in specs) {
    const value = intr.data[spec.field]
    if (value == null) {
      continue
    }
    if (spec.matchSubpaths) {
      match[spec.field] = "{${value},${value}/**}"
    } else {
      match[spec.field] = value
    }
  }
  return match
}

/**
 * Return a new policy with a catch-all rule (`{ action }` with no
 * `match`) for `effect` appended. Pure — does not mutate the input.
 *
 * ## Precedence trap
 *
 * Evaluation is first-match-wins, so **append order matters**. A
 * second call for the same effect with a different action is dead
 * code:
 *
 * ```ts
 * let p = recordRule({}, "std::read", "reject")
 * p = recordRule(p, "std::read", "approve")  // never reached
 * ```
 *
 * If you're flipping a previously-recorded decision, decide
 * explicitly: either reset the effect's rules first
 * (`{ ...policy, "std::read": [] }` and re-record), or hand-edit
 * `policy[effect]` to replace the offending rule. This function does
 * not try to detect or warn about shadowing on your behalf.
 */
export def recordRule(
  policy: Policy,
  effect: InterruptEffect,
  action: "approve" | "reject",
): Policy {
  """
  Return a new policy with a catch-all rule for an effect appended. A single bare rule covers every future interrupt of that effect.

  @param policy - The policy to extend (not mutated).
  @param effect - The interrupt effect the rule applies to.
  @param action - Whether to approve or reject matching interrupts.
  """
  const existing = policy[effect] || []
  const next: Policy = {
    ...policy,
    [effect]: [...existing, {
      action: action
    }]
  }
  return next
}

/**
 * Return a new policy with a scoped approve rule prepended for
 * `intr.effect`. The rule's `match` is built by `buildScopedMatch`,
 * so it pins whichever fields are configured for the effect. Pure.
 *
 * Prepended (not appended) so the new, more-specific rule wins
 * over any pre-existing catch-all in first-match-wins order. This
 * makes scoped rules safe to add even if the effect already has a
 * broader rejection: the scoped approval applies first when it
 * matches, otherwise the catch-all takes over.
 *
 * The action is always `"approve"`, because the (ap) UI affordance
 * only makes sense in the affirmative direction. Build a scoped
 * reject by hand if you need one.
 */
export def recordScopedRule(
  policy: Policy,
  intr: Record<string, any>,
  fields: ScopedRuleFields,
): Policy {
  """
  Return a new policy with a scoped approve rule prepended for the interrupt's effect. The rule pins the configured fields, so it approves only future interrupts matching this one's field values.

  @param policy - The policy to extend (not mutated).
  @param intr - The interrupt whose field values to pin.
  @param fields - Per-effect config naming which data fields to pin.
  """
  const match = buildScopedMatch(intr, fields)
  const existing = policy[intr.effect] || []
  const next: Policy = {
    ...policy,
    [intr.effect]: [{
      match: match,
      action: "approve"
    }, ...existing]
  }
  return next
}

export type ParsePolicyFailureStatus =
  | "doesnt-exist"
  | "read-error"
  | "malformed-json"
  | "policy-not-valid"

export type ParsePolicyFailure = {
  status: ParsePolicyFailureStatus;
  error?: string
}

/**
 * Read + JSON-parse + validate a policy file from disk. Returns `{}`
 * (an empty policy) on any failure: a missing file, unreadable
 * permissions, malformed JSON, or a schema-validation error. It also
 * prints a warning so the user knows their saved decisions did not
 * carry over.
 *
 * Raises `std::read` (so the caller's handler chain controls
 * whether the read is approved). The CLI handler auto-approves
 * this via `with approve`.
 */
export def parsePolicyFile(path: string): Result<Policy, ParsePolicyFailure> {
  """
  Read + parse + validate a policy file from disk. Returns {} on any
  failure (missing, unreadable, malformed JSON, invalid schema) with a
  warning to the user.

  @param path - The policy file path
  """
  const dir = dirname(path)
  const name = basename(path)
  if (!exists(name, dir)) {
    return failure({
      status: "doesnt-exist"
    })
  }
  const readResult = read(name, dir) with approve
  if (isFailure(readResult)) {
    return failure({
      status: "read-error",
      error: readResult.error
    })
  }
  const parsed = try JSON.parse(readResult.value)
  if (isFailure(parsed)) {
    return failure({
      status: "malformed-json"
    })
  }
  const valid = validatePolicy(parsed.value)
  if (!valid.success) {
    return failure({
      status: "policy-not-valid",
      error: valid.error
    })
  }
  return parsed
}

/**
  * Set the policy to be used with the CLI handler
  * returned by `cliPolicyHandler`. The handler's internal state
  * is module-level, so this sets the policy for the handler
  * to consult on every interrupt. Call this after loading a policy
  * with `parsePolicyFile` or constructing one by hand.
  */
export def setPolicy(path: string, policy: Policy) {
  """Install `policy` as the active policy and persist it to `path`."""
  _policy = policy
  _policyLoaded = true
  writePolicyFile(path, policy, []) with approve
}

/**
 * Validate a `Policy` and write it as JSON to `path`. Throws (returns
 * `Failure`) if validation fails. Invalid policies are never
 * persisted.
 *
 * `allowedPaths` is a defense-in-depth allow-list passed straight
 * through to the underlying `write`. Pass `[]` (the default) only
 * when the path is trusted. Otherwise restrict it to a known
 * directory like `["${env("HOME")}/.myapp"]`.
 */
export def writePolicyFile(
  path: string,
  policy: Policy,
  allowedPaths: string[] = [],
) {
  """
  Validate and write a policy to a JSON file. Throws if the policy is invalid.

  @param path - The destination file path.
  @param policy - The policy to write.
  @param allowedPaths - Restrict writes to these path prefixes; empty allows any path.
  """
  return try _writePolicyFile(path, policy, allowedPaths)
}

// Lazy-load the on-disk policy on first handler invocation. Uses
// flip-flag-first so the std::read interrupt's chain re-entry sees
// `_policyLoaded == true` and returns without recursing. Missing or
// invalid policy files start from an empty policy. Direct callers can
// inspect `parsePolicyFile` for the exact failure status.
def maybeLoadPolicy() {
  if (!_policyLoaded) {
    _policyLoaded = true
    _internalIo = true
    const loaded = parsePolicyFile(globalCliPolicyOpts.file) with approve
    _internalIo = false
    if (isFailure(loaded)) {
      _policy = {}
    } else {
      _policy = loaded.value
    }
  }
}

// Persist any pending policy changes. Same flip-flag-first pattern
// as `maybeLoad`. Runs at the top of every handler invocation so a
// decision recorded on turn N is on disk before turn N+1's first
// interrupt fires. Decisions on the FINAL interrupt of a session
// may not flush. `flushPolicy()` is the escape hatch for that.
def maybeFlush() {
  if (_pendingSave) {
    // flip FIRST
    _pendingSave = false
    // `_pendingSave` is only set after a decision is recorded, which also
    // sets `_policy` — but if that invariant ever breaks, skip the write
    // rather than clobber the policy file with `null`.
    if (_policy != null) {
      _internalIo = true
      writePolicyFile(globalCliPolicyOpts.file, _policy, []) with approve
      _internalIo = false
    }
  }
}

/**
 * Force-write the `cliPolicyHandler`'s in-memory policy to disk
 * now. Use between user turns when you want the **last** decision
 * of a session persisted. The handler's own auto-flush runs at the
 * top of the next interrupt, so a decision recorded on the final
 * interrupt of a turn won't survive a crash unless you call this.
 *
 * No-op when there are no pending changes. Auto-approves its own
 * `std::write` via `with approve` (you opted in by installing the
 * handler).
 */
export def flushPolicy() {
  """Write any pending always-rule additions to the policy file now."""
  if (_pendingSave) {
    _pendingSave = false
    if (_policy != null) {
      _internalIo = true
      writePolicyFile(globalCliPolicyOpts.file, _policy, []) with approve
      _internalIo = false
    }
  }
}

// Human-readable summary of the match `buildScopedMatch` would
// produce, shown next to the (ap) prompt so the user sees the
// exact rule they're agreeing to before they say yes.
def describeScopedMatch(intr: any): string {
  const match = buildScopedMatch(intr, globalCliPolicyOpts.fields)
  const ks = keys(match)
  if (ks.length == 0) {
    return "(no scoped fields)"
  }
  let parts: string[] = []
  for (k in ks) {
    parts.push("${k}=${match[k]}")
  }
  return parts.join(", ")
}

/**
 * Result returned by `askUser`. When the user picks a known key,
 * `action` is the corresponding `Decision` and `reason` is null.
 * When `allowFreeText` lets the user type a free-form rejection
 * reason, `action` is `"reject"` and `reason` is the typed text.
 * The handler uses it as the reject reason directly, skipping the
 * follow-up "Why are you rejecting?" prompt.
 */
type AskUserResult = {
  action: Decision;
  reason?: string
}

// Render a `std::edit` interrupt's payload as a colored diff. `payload` is
// the interrupt's `data` ({filename, before, after, ...}). It shows the
// filename as a header and renders the change as a unified diff.
def renderEditDiff(payload: any): string {
  let lines = []
  lines.push(color.yellow("⏺ Edit: ${payload.filename}"))
  lines.push(
    diff(
    payload.before || "",
    payload.after || "",
    language: "auto",
    color: true,
    lineNumbers: true,
    context: 3,
    ignoreWhitespace: true,
  ),
  )
  return lines.join("\n")
}

// Render the data of one interrupt for the approval prompt. `data` is the
// interrupt's payload (`intr.data`); `effect` is `intr.effect`, used to
// special-case effects that have a nicer representation than a key/value
// table, currently `std::edit`, which renders as a diff.
def prettyPrintInterruptData(data: any, effect: string = ""): string {
  // JS reports `null` as `typeof == "object"`, so guard explicitly.
  // Otherwise `for (key in data)` would throw and crash the interrupt
  // prompt.
  if (data == null) {
    return ""
  }

  if (typeof data == "string") {
    return data
  }

  // An edit reads best as a diff. Show any non-content metadata (e.g.
  // `dir`) as a table, then the change as a colored diff. We exclude
  // `before`/`after` (huge, shown in the diff), `edits` (the raw op list,
  // redundant with the diff), and `filename` (already in the diff header).
  if (effect == "std::edit") {
    const meta = renderObjAsTable(
      data,
      excludeKeys: ["before", "after", "filename", "edits"],
    )
    const editDiff = renderEditDiff(data)
    if (meta == "") {
      return editDiff
    }
    return "${meta}\n\n${editDiff}"
  }

  // Arrays are `typeof == "object"` too, but `for (x in array)` yields
  // the ELEMENTS, not indices, so the object branch below would pass a
  // non-string element straight into `text()` and crash `render` with
  // `content.split is not a function`. Handle arrays explicitly, keyed
  // by index, recursing into each element.
  if (Array.isArray(data)) {
    let i = 0
    const paramTable = table() as t {
      for (item in data) {
        t.row(text("${i}", bold: true), text(prettyPrintInterruptData(item)))
        i = i + 1
      }
    }
    return render(paramTable)
  }
  if (typeof data == "object") {
    return renderObjAsTable(data)
  }
  return JSON.stringify(data)
}

// Render an object's key/value pairs as a two-column table. Keys in
// `excludeKeys` are skipped. Returns "" when no rows remain so callers can
// omit an empty table.
def renderObjAsTable(
  obj: Record<string, any>,
  excludeKeys: string[] = null,
): string {
  const skip = excludeKeys || []
  let rows = 0
  const paramTable = table() as t {
    for (key in obj) {
      if (skip.includes(key)) {
        continue
      }
      t.row(text(key, bold: true), text(prettyPrintInterruptData(obj[key])))
      rows = rows + 1
    }
  }
  if (rows == 0) {
    return ""
  }
  return render(paramTable)
}

// Present the (a)/(r)/(aa)/(ap)/(rr) menu for one interrupt and
// return the user's choice as an `AskUserResult`. `(ap)` is offered
// only when `globalCliPolicyOpts.fields` has an entry for `intr.effect`.
// Renders as a modal over the active repl(); falls back to plain print
// + input when no REPL is running (e.g. headless agent runs).
//
// We pass `allowFreeText: true` to `interruptChoice` so the user can
// type a rejection reason instead of picking a key. That single
// keystroke flow replaces the old two-step "(r), then type reason".
def askUser(intr: any): AskUserResult {
  // `!= null` compiles to `!== null` (Agency strict equality), so an
  // undefined entry would slip through. Use the explicit existence check.
  let effectFields: ScopedField[] = []
  if (globalCliPolicyOpts.fields[intr.effect] != undefined) {
    effectFields = globalCliPolicyOpts.fields[intr.effect]
  }
  const scopedAvailable = effectFields.length > 0

  const title = intr.message
  const body = prettyPrintInterruptData(intr.data, intr.effect)

  let items: ChoiceItem[] = [
    {
    key: "a",
    label: "approve once"
  },
    {
    key: "r",
    label: "reject once"
  },
    {
    key: "aa",
    label: "approve always (every future ${intr.effect})"
  },
  ]
  if (scopedAvailable) {
    items.push(
      {
      key: "ap",
      label: "approve always here (${describeScopedMatch(intr)})"
    },
    )
  }
  items.push({
    key: "rr",
    label: "reject always"
  })

  // `allowCancel: true` makes Escape cancel the whole request (raises a
  // cancellation that unwinds the turn back to the REPL prompt) instead of
  // re-prompting. The "r" option still lets you reject a single action
  // (and optionally tell the agent what to do instead).
  const answer = withLock("std::tty") {
    return interruptChoice(title, body, items, allowFreeText: true, allowCancel: true)
  }
  return match(answer) {
    "a" => ({
      action: "approve",
      reason: null
    })
    "r" => ({
      action: "reject",
      reason: null
    })
    "aa" => ({
      action: "approve-always",
      reason: null
    })
    "ap" => ({
      action: "approve-always-here",
      reason: null
    })
    "rr" => ({
      action: "reject-always",
      reason: null
    })
    _ => ({
      action: "reject",
      reason: answer
    })
  }
}

/**
 * Internal — the actual handler function returned by
 * `cliPolicyHandler`. Do not call directly; use `cliPolicyHandler`
 * to construct one.
 */
def _handler(intr: any): any {
  // Re-entrancy guard: while we read/write our OWN policy file (inside
  // maybeLoadPolicy / maybeFlush below), that file op raises a nested
  // std::read / std::write that re-enters this handler. Approve it
  // directly so the file op's own `with approve` actually takes effect.
  // Without this, the `_policy == null` default-reject (or a no-match
  // propagate) would veto it and the load/save would silently fail,
  // making every real interrupt prompt. See `_internalIo`.
  if (_internalIo) {
    return approve()
  }

  // A guard trip is not a policy decision. It is owned by whatever `handle`
  // block wraps the guard, which alone knows how much extra budget to grant
  // (an approval with too little is refused by the runtime). Passing here
  // lets that handler's verdict stand; an empty approve/reject or a
  // `propagate` from this generic handler would either be refused or, since
  // propagate outranks approve, override the real grant and re-prompt the
  // user. The auto-approve handler passes on `std::guard` for the same
  // reason (see agencyFunction.ts).
  if (intr.effect == "std::guard") {
    return pass()
  }

  maybeLoadPolicy()
  maybeFlush()

  if (_policy == null) {
    print(
      "Error: no policy loaded. Rejecting by default. Load a policy with parsePolicyFile and set it using setPolicy before installing the handler.",
    )
    return reject()
  }

  const decision = checkPolicy(_policy, intr)
  if (decision.type == "approve") {
    // Auto-approved: the user never sees the approval prompt (which would
    // render an edit as a diff), so surface an edit's diff here. Other
    // effects auto-approve silently as before.
    if (intr.effect == "std::edit") {
      print(renderEditDiff(intr.data))
    }
    return approve()
  }
  if (decision.type == "reject") {
    return reject()
  }

  const answer = askUser(intr)
  if (answer.action == "approve-always") {
    _policy = recordRule(_policy, intr.effect, "approve")
    _pendingSave = true
    return approve()
  }
  if (answer.action == "approve-always-here") {
    _policy = recordScopedRule(_policy, intr, globalCliPolicyOpts.fields)
    _pendingSave = true
    return approve()
  }
  if (answer.action == "reject-always") {
    _policy = recordRule(_policy, intr.effect, "reject")
    _pendingSave = true
    return reject()
  }
  if (answer.action == "approve") {
    return approve()
  }

  // Free-text path: the user typed a rejection reason at the choice
  // prompt instead of picking a key. Use it directly so the LLM sees
  // their feedback without a second prompt.
  if (answer.reason != null) {
    return reject(answer.reason)
  }

  // Interactive reject (user picked "r"): ask for optional guidance so
  // the LLM sees concrete feedback as the tool's return value and the
  // agent can course-correct without ending the turn.
  const reason = input("What should the agent do instead? (press enter to skip) ")
  if (reason == "") {
    return reject()
  }
  return reject(reason)
}

/**
 * Drop-in policy handler for interactive CLI agents. Returns a
 * function ref you bind to a local variable and install on a `handle`
 * block:
 *
 * ```ts
 * const handler = cliPolicyHandler(
 *   file: "${env("HOME")}/.myapp/policy.json",
 *   fields: { "std::read": [{ field: "dir", matchSubpaths: true }] },
 * )
 * handle {
 *   // every interrupt raised here is filtered through the handler
 * } with handler
 * ```
 *
 * What the handler does:
 *
 * 1. **Loads** the policy file on first invocation. Missing /
 *    malformed files are treated as `{}` with a warning.
 * 2. **Consults the loaded policy** via `checkPolicy`. If a rule
 *    matches, approves or rejects without prompting.
 * 3. **Prompts the user** when no rule applies, showing
 *    (a)/(r)/(aa)/(ap)/(rr). The (ap) option appears only when
 *    `fields` has an entry for the interrupt's effect.
 * 4. **Records "always" decisions** in memory and flushes them to
 *    disk at the top of the next interrupt. Use `flushPolicy()` if
 *    you need the final decision of a session persisted before
 *    process exit.
 *
 * ## Singleton state
 *
 * Internal state (loaded policy, pending-save flag, options) is
 * module-level. Calling `cliPolicyHandler` more than once in the
 * same program silently overwrites the previous options. Only the
 * last `file` / `fields` win. For multi-policy agents, fork the
 * module or use the pure primitives directly.
 *
 * ## Bind-to-variable requirement
 *
 * The `with` clause only accepts an identifier (not a call
 * expression), so you MUST bind the return value to a `const`
 * before using it. This also bypasses the typechecker's
 * handler-raises-interrupt rule, which only resolves direct
 * functionRef names. The flip-flag-first pattern inside the handler
 * provides runtime safety.
 *
 * @param file - Path to the on-disk policy file. Created on first
 *   save. The containing directory must already exist.
 * @param fields - Per-effect config controlling the (ap) prompt
 *   option. Effects not present here don't offer (ap).
 * @param policy - Optional in-memory policy to start from. When
 *   provided, the handler uses it directly and does NOT read `file` on
 *   startup (so there is no load-time `std::read` and no dependency on
 *   `file` existing). New "always" decisions still persist to `file`.
 *   Use for a per-run override that must not be seeded from — or written
 *   over — a saved policy on disk. Omit (null) for the normal
 *   load-from-`file` behavior.
 */
export def cliPolicyHandler(
  file: string,
  fields: ScopedRuleFields,
  policy: Policy | null = null,
): any {
  """
  CLI sugar for an interactive policy handler. Loads and saves the policy file, prompts the user on new interrupts, records "always" decisions, and returns approve/reject. Install on the outermost `handle`. Call exactly once per program — internal state is module-level.

  @param file - Path to the on-disk policy file.
  @param fields - Per-effect config controlling the "approve-always-here" prompt option.
  @param policy - Optional in-memory policy to use directly instead of loading `file` on startup.
  """
  globalCliPolicyOpts = {
    file: file,
    fields: fields
  }
  if (policy != null) {
    // Seed from the caller and mark loaded so `maybeLoadPolicy` skips the
    // file read; persistence of later decisions still targets `file`.
    _policy = policy
    _policyLoaded = true
  } else {
    _policy = null
    _policyLoaded = false
  }
  _pendingSave = false
  return _handler
}
