///|
/// The gateway state machine.
///
/// The engine is deliberately transport-agnostic: it consumes one protocol
/// line at a time and returns a list of actions for the host to execute. In
/// production the host is a Node process that owns the two pipes; in tests the
/// same engine is driven with plain strings. That split keeps every security
/// decision in MoonBit while the host only performs I/O.
///
/// Invariants the engine maintains:
///
/// - A denied `tools/call` is never forwarded, so the upstream server cannot
///   perform the side effect.
/// - An approval is bound to the canonical digest of the exact tool name and
///   arguments, expires, and is consumed once.
/// - Values from sensitive results are fingerprinted and matched against the
///   arguments of egress tools before any rule is applied.
/// - Every decision produces exactly one hash-chained ledger event.
struct Inflight {
  call : String
  tool : String
  sensitive : Bool
}

///|
struct PendingApproval {
  call : String
  tool : String
  digest : String
  line : String
  key : String
  id : Json?
  rule : String
  expires : Int64
  sensitive : Bool
  revealed : Int32
}

///|
/// A protected value kept in memory for the lifetime of one session. It is
/// never written to the ledger; only `preview` and the fingerprint leave the
/// process.
struct SecretEntry {
  value : String
  preview : String
  call : String
  tool : String
}

///|
pub struct Session {
  id : String
  policy : Policy
  mut seq : Int32
  mut prev_hash : String
  mut call_seq : Int32
  inflight : Map[String, Inflight]
  lists : Map[String, Bool]
  pending : Map[String, PendingApproval]
  taint : Map[String, TaintSource]
  vault : Map[String, SecretEntry]
  mut attempts : Array[Int64]
  mut allowed : Int32
  mut denied : Int32
  mut approval_requests : Int32
  mut forwarded : Int32
  mut tainted_values : Int32
  mut total_calls : Int32
  mut arg_bytes : Int64
  mut reveal_count : Int32
}

///|
pub fn new_session(id : String, policy : Policy) -> Session {
  {
    id,
    policy,
    seq: 0,
    prev_hash: "",
    call_seq: 0,
    inflight: Map([]),
    lists: Map([]),
    pending: Map([]),
    taint: Map([]),
    vault: Map([]),
    attempts: [],
    allowed: 0,
    denied: 0,
    approval_requests: 0,
    forwarded: 0,
    tainted_values: 0,
    total_calls: 0,
    arg_bytes: 0,
    reveal_count: 0,
  }
}

///|
/// First ledger event of a run: which policy governed it, identified by
/// content hash, so a report can be tied to an exact policy revision.
pub fn open_session(session : Session, now : Int64) -> Json {
  ledger_event(session, now, "session_open", [
    ("engine", json_str("0.2.0")),
    ("policy", json_str(session.policy.name)),
    ("policy_digest", json_str(session.policy.digest)),
    ("rules", json_int(session.policy.rules.length())),
    ("default", json_str(session.policy.default_decision)),
    ("rate_window_ms", json_int(session.policy.rate.window_ms)),
    ("rate_max_calls", json_int(session.policy.rate.max_calls)),
    ("approval_ttl_ms", json_int(session.policy.approval_ttl_ms)),
    ("min_secret_len", json_int(session.policy.min_secret_len)),
    ("hide_denied", json_bool(session.policy.hide_denied)),
  ])
}

///|
fn action_upstream(line : String) -> Json {
  json_obj([("kind", json_str("upstream")), ("line", json_str(line))])
}

///|
fn action_client(line : String) -> Json {
  json_obj([("kind", json_str("client")), ("line", json_str(line))])
}

///|
fn action_notice(level : String, message : String) -> Json {
  json_obj([
    ("kind", json_str("notice")),
    ("level", json_str(level)),
    ("message", json_str(message)),
  ])
}

///|
/// Append one event to the hash chain and return the host action that writes
/// it. The chain input is `previous_hash + "\n" + canonical(event)`; the hash
/// itself is excluded, so verification can recompute it from the stored line.
fn ledger_event(
  session : Session,
  now : Int64,
  kind : String,
  fields : Array[(String, Json)],
) -> Json {
  let pairs : Array[(String, Json)] = [
    ("seq", json_int(session.seq)),
    ("ts", json_i64(now)),
    ("session", json_str(session.id)),
    ("kind", json_str(kind)),
  ]
  for field in fields {
    pairs.push(field)
  }
  let chained : Array[(String, Json)] = []
  for pair in pairs {
    chained.push(pair)
  }
  chained.push(("prev", json_str(session.prev_hash)))
  // `prev` is part of the hashed body: verification strips only `hash` and
  // recomputes, so the chain commits to its own predecessor.
  let body = json_obj(chained)
  let hash = sha256_hex(session.prev_hash + "\n" + json_canonical(body))
  chained.push(("hash", json_str(hash)))
  session.prev_hash = hash
  session.seq += 1
  json_obj([
    ("kind", json_str("ledger")),
    ("line", json_str(json_render(json_obj(chained)))),
  ])
}

///|
fn evidence_json(hits : Array[TaintHit]) -> Json {
  let items : Array[Json] = []
  for hit in hits {
    items.push(
      json_obj([
        ("fingerprint", json_str(hit.fingerprint)),
        ("preview", json_str(hit.preview)),
        ("source_call", json_str(hit.source.call)),
        ("source_tool", json_str(hit.source.tool)),
      ]),
    )
  }
  json_arr(items)
}

///|
fn object_keys(value : Json) -> Array[String] {
  let keys : Array[String] = []
  match value {
    Object(fields) =>
      for key, _ in fields {
        keys.push(key)
      }
    _ => ()
  }
  keys
}

///|
/// Canonical digest of a call. Approvals and ledger entries use this value so
/// that a grant for `write reports/a.md` cannot be replayed for
/// `write reports/b.md`.
pub fn call_digest(tool : String, args : Json) -> String {
  sha256_hex(
    json_canonical(json_obj([("tool", json_str(tool)), ("arguments", args)])),
  )
}

///|
priv struct Verdict {
  decision : String
  rule : String
  reason : String
  hits : Array[TaintHit]
  ttl_ms : Int32
}

///|
fn rate_exceeded(session : Session, now : Int64) -> Bool {
  let window_start = now - session.policy.rate.window_ms.to_int64()
  let kept : Array[Int64] = []
  for stamp in session.attempts {
    if stamp > window_start {
      kept.push(stamp)
    }
  }
  session.attempts = kept
  let exceeded = kept.length() >= session.policy.rate.max_calls
  kept.push(now)
  session.attempts = kept
  exceeded
}

///|
/// Decide one call. Order matters and is documented in `docs/POLICY.md`:
/// rate first (cheap, protects the upstream), then data provenance (the
/// strongest guarantee), then the ordered rule list, then the default.
fn decide(
  session : Session,
  tool : String,
  args : Json,
  now : Int64,
) -> Verdict {
  let policy = session.policy
  if rate_exceeded(session, now) {
    return {
      decision: "deny",
      rule: "rate-limit",
      reason: "session exceeded \{policy.rate.max_calls} calls per \{policy.rate.window_ms} ms",
      hits: [],
      ttl_ms: 0,
    }
  }
  let arg_size = @utf8.encode(json_canonical(args)).length().to_int64()
  if policy.budget.max_calls > 0 &&
    session.total_calls >= policy.budget.max_calls {
    return {
      decision: "deny",
      rule: "budget-calls",
      reason: "session call budget exhausted (\{policy.budget.max_calls})",
      hits: [],
      ttl_ms: 0,
    }
  }
  if policy.budget.max_single_bytes > 0 &&
    arg_size > policy.budget.max_single_bytes.to_int64() {
    return {
      decision: "deny",
      rule: "budget-operation",
      reason: "single operation exceeds \{policy.budget.max_single_bytes} bytes",
      hits: [],
      ttl_ms: 0,
    }
  }
  if policy.budget.max_arg_bytes > 0 &&
    session.arg_bytes + arg_size > policy.budget.max_arg_bytes.to_int64() {
    return {
      decision: "deny",
      rule: "budget-bytes",
      reason: "argument budget exhausted (\{session.arg_bytes}/\{policy.budget.max_arg_bytes} bytes)",
      hits: [],
      ttl_ms: 0,
    }
  }
  session.total_calls += 1
  session.arg_bytes += arg_size
  let hits = if is_egress(policy, tool) {
    find_taint(session.taint, collect_strings(args), policy.min_secret_len)
  } else {
    []
  }
  if hits.length() > 0 {
    return {
      decision: policy.on_taint,
      rule: "egress-taint",
      reason: "arguments carry \{hits.length()} value(s) seen in sensitive results",
      hits,
      ttl_ms: policy.approval_ttl_ms,
    }
  }
  match match_rule(policy, tool, args) {
    Some(hit) =>
      return {
        decision: hit.decision,
        rule: hit.id,
        reason: hit.reason,
        hits: [],
        ttl_ms: if hit.ttl_ms > 0 {
          hit.ttl_ms
        } else {
          policy.approval_ttl_ms
        },
      }
    None => ()
  }
  {
    decision: policy.default_decision,
    rule: "default",
    reason: "no rule matched",
    hits: [],
    ttl_ms: policy.approval_ttl_ms,
  }
}

///|
fn denial_message(verdict : Verdict) -> String {
  let detail = if verdict.reason.is_empty() {
    "rule \{verdict.rule}"
  } else {
    verdict.reason
  }
  "Blocked by MoonGate policy (\{verdict.rule}): \{detail}"
}

///|
fn denial_payload(
  verdict : Verdict,
  call : String,
  tool : String,
  digest : String,
) -> Json {
  json_obj([
    ("decision", json_str(verdict.decision)),
    ("rule", json_str(verdict.rule)),
    ("reason", json_str(verdict.reason)),
    ("call", json_str(call)),
    ("tool", json_str(tool)),
    ("digest", json_str(digest)),
    ("evidence", evidence_json(verdict.hits)),
  ])
}

///|
fn deny_response(
  msg : RpcMessage,
  verdict : Verdict,
  call : String,
  tool : String,
  digest : String,
) -> Json {
  error_response(
    msg.id,
    -32001,
    denial_message(verdict),
    Some(json_obj([("moongate", denial_payload(verdict, call, tool, digest))])),
  )
}

///|
fn approval_summary(
  args : Json,
  taint : Map[String, TaintSource],
  min_len : Int32,
) -> Json {
  match args {
    Object(fields) => {
      let pairs : Array[(String, Json)] = []
      for key, value in fields {
        match value {
          String(s) => {
            let hits = find_taint(taint, [s], min_len)
            let shown = if hits.length() > 0 {
              "«redacted»"
            } else if s.length() > 120 {
              s[0:120].to_owned() + "…"
            } else {
              s
            }
            pairs.push((key, json_str(shown)))
          }
          Number(n, ..) => pairs.push((key, Json::number(n)))
          True | False | Null => pairs.push((key, value))
          _ => pairs.push((key, json_str("«object»")))
        }
      }
      json_obj(pairs)
    }
    _ => json_obj([])
  }
}

///|
fn remember_raw(
  session : Session,
  call : String,
  tool : String,
  raws : Array[RawToken],
) -> Int32 {
  let mut count = 0
  for raw in raws {
    if !session.taint.contains(raw.fingerprint) {
      let source = taint_source(call, tool, raw.preview)
      session.taint.set(raw.fingerprint, source)
      // Remember encoded and collapsed forms too, so a later call carrying a
      // Base64 or split/re-cased copy is still recognised as the same value.
      for variant in variant_values(raw.value) {
        let fingerprint = fingerprint_of(variant)
        if !session.taint.contains(fingerprint) {
          session.taint.set(fingerprint, source)
        }
      }
      count += 1
    }
    if !session.vault.contains(raw.fingerprint) {
      session.vault.set(raw.fingerprint, {
        value: raw.value,
        preview: raw.preview,
        call,
        tool,
      })
    }
  }
  session.tainted_values += count
  count
}

///|
fn mask_text(text : String, raws : Array[RawToken]) -> String {
  let mut out = text
  for raw in raws {
    out = out.replace_all(old=raw.value, new=placeholder_for(raw.fingerprint))
  }
  out
}

///|
fn placeholders_evidence(
  session : Session,
  fingerprints : Array[String],
) -> Array[TaintHit] {
  let hits : Array[TaintHit] = []
  for fingerprint in fingerprints {
    match session.vault.get(fingerprint) {
      Some(entry) =>
        hits.push({
          fingerprint,
          preview: entry.preview,
          source: taint_source(entry.call, entry.tool, entry.preview),
        })
      None => ()
    }
  }
  hits
}

///|
fn vault_values(session : Session) -> Map[String, String] {
  let values : Map[String, String] = Map([])
  for fingerprint, entry in session.vault {
    values.set(fingerprint, entry.value)
  }
  values
}

///|
fn rebuild_with_params(msg : RpcMessage, params : Json) -> String {
  match msg.raw {
    Object(fields) => {
      let copy : Map[String, Json] = Map([])
      for key, value in fields {
        copy.set(key, value)
      }
      copy.set("params", params)
      json_render(Json::object(copy))
    }
    _ => json_render(msg.raw)
  }
}

///|
fn decision_ledger(
  session : Session,
  now : Int64,
  tool : String,
  call : String,
  verdict : Verdict,
  digest : String,
  args : Json,
  via : String,
  revealed : Int32,
  reveal_rule : String,
) -> Json {
  ledger_event(session, now, "decision", [
    ("tool", json_str(tool)),
    ("call", json_str(call)),
    ("decision", json_str(verdict.decision)),
    ("rule", json_str(verdict.rule)),
    ("reason", json_str(verdict.reason)),
    ("digest", json_str(digest)),
    ("via", json_str(via)),
    ("args_keys", json_strings(object_keys(args))),
    ("revealed", json_int(revealed)),
    ("reveal_rule", json_str(reveal_rule)),
    ("evidence", evidence_json(verdict.hits)),
  ])
}

///|
fn protocol_error(
  session : Session,
  now : Int64,
  message : String,
  line : String,
) -> Array[Json] {
  let actions : Array[Json] = []
  actions.push(
    ledger_event(session, now, "protocol_error", [
      ("message", json_str(message)),
      ("bytes", json_int(line.length())),
    ]),
  )
  actions.push(
    action_client(
      json_render(
        error_response(None, -32700, "MoonGate: invalid JSON-RPC message", None),
      ),
    ),
  )
  actions
}

///|
fn handle_tool_call(
  session : Session,
  msg : RpcMessage,
  verb : String,
  line : String,
  now : Int64,
) -> Array[Json] {
  let actions : Array[Json] = []
  let tool = if verb == "tools/call" {
    match tool_name_of(msg.params) {
      Some(name) =>
        if name.is_empty() || name.length() > 200 {
          return [
            action_client(
              json_render(
                error_response(
                  msg.id,
                  -32602,
                  "MoonGate: tools/call requires a nonempty params.name",
                  None,
                ),
              ),
            ),
          ]
        } else {
          name
        }
      None =>
        return [
          action_client(
            json_render(
              error_response(
                msg.id,
                -32602,
                "MoonGate: tools/call requires params.name",
                None,
              ),
            ),
          ),
        ]
    }
  } else {
    capability_name(verb)
  }
  let args = capability_args(verb, msg.params)
  let digest = call_digest(tool, args)
  session.call_seq += 1
  let call = "c\{session.call_seq}"
  let fingerprints = placeholders_in(args)
  let mut forward_line = line
  let mut revealed = 0
  let mut reveal_rule = ""
  if !fingerprints.is_empty() {
    match match_reveal(session.policy, tool, args) {
      None => {
        let verdict : Verdict = {
          decision: "deny",
          rule: "secret-reveal",
          reason: "destination is not allowed to receive a protected value",
          hits: placeholders_evidence(session, fingerprints),
          ttl_ms: 0,
        }
        actions.push(
          decision_ledger(
            session, now, tool, call, verdict, digest, args, "client", 0, "",
          ),
        )
        session.denied += 1
        actions.push(
          action_client(
            json_render(deny_response(msg, verdict, call, tool, digest)),
          ),
        )
        return actions
      }
      Some(rule) => {
        reveal_rule = rule.id
        revealed = fingerprints.length()
        if session.policy.budget.max_reveals > 0 &&
          session.reveal_count + revealed > session.policy.budget.max_reveals {
          let verdict : Verdict = {
            decision: "deny",
            rule: "budget-reveals",
            reason: "reveal budget exhausted (\{session.policy.budget.max_reveals})",
            hits: placeholders_evidence(session, fingerprints),
            ttl_ms: 0,
          }
          actions.push(
            decision_ledger(
              session, now, tool, call, verdict, digest, args, "client", 0, "",
            ),
          )
          session.denied += 1
          actions.push(
            action_client(
              json_render(deny_response(msg, verdict, call, tool, digest)),
            ),
          )
          return actions
        }
        let values = vault_values(session)
        let params = map_json_strings(
          match msg.params {
            Some(params) => params
            None => Json::null()
          },
          fn(text) { reveal_placeholders(text, values).0 },
        )
        forward_line = rebuild_with_params(msg, params)
      }
    }
  }
  let verdict = decide(session, tool, args, now)
  actions.push(
    decision_ledger(
      session, now, tool, call, verdict, digest, args, "client", revealed, reveal_rule,
    ),
  )
  match verdict.decision {
    "allow" => {
      session.allowed += 1
      session.forwarded += 1
      let key = id_key(msg.id)
      session.inflight.set(key, {
        call,
        tool,
        sensitive: is_sensitive(session.policy, tool, args),
      })
      session.reveal_count += revealed
      actions.push(action_upstream(forward_line))
    }
    "approval" => {
      session.approval_requests += 1
      let expires = now + verdict.ttl_ms.to_int64()
      session.pending.set(call, {
        call,
        tool,
        digest,
        line: forward_line,
        key: id_key(msg.id),
        id: msg.id,
        rule: verdict.rule,
        expires,
        sensitive: is_sensitive(session.policy, tool, args),
        revealed,
      })
      actions.push(
        json_obj([
          ("kind", json_str("approval")),
          ("call", json_str(call)),
          ("tool", json_str(tool)),
          ("digest", json_str(digest)),
          ("rule", json_str(verdict.rule)),
          ("reason", json_str(verdict.reason)),
          ("expires_at", json_i64(expires)),
          (
            "summary",
            approval_summary(args, session.taint, session.policy.min_secret_len),
          ),
        ]),
      )
    }
    _ => {
      session.denied += 1
      actions.push(
        action_client(
          json_render(deny_response(msg, verdict, call, tool, digest)),
        ),
      )
    }
  }
  actions
}

///|
fn handle_client_message(
  session : Session,
  msg : RpcMessage,
  line : String,
  now : Int64,
) -> Array[Json] {
  match msg.verb {
    None =>
      // A response from the client to a server-initiated request. Forward it
      // and record only its shape.
      [
        action_upstream(line),
        ledger_event(session, now, "client_response", [
          ("id", json_str(id_key(msg.id))),
          ("bytes", json_int(line.length())),
        ]),
      ]
    Some(verb) =>
      if verb == "tools/call" ||
        verb == "resources/read" ||
        verb == "prompts/get" {
        handle_tool_call(session, msg, verb, line, now)
      } else if is_passthrough_method(verb) {
        if verb == "tools/list" && msg.has_id {
          session.lists.set(id_key(msg.id), true)
        }
        session.forwarded += 1
        [
          action_upstream(line),
          ledger_event(session, now, "passthrough", [
            ("method", json_str(verb)),
            ("bytes", json_int(line.length())),
          ]),
        ]
      } else {
        session.denied += 1
        let response = error_response(
          msg.id,
          -32601,
          "MoonGate: method \{verb} is not allowed by policy",
          None,
        )
        [
          ledger_event(session, now, "unsupported", [("method", json_str(verb))]),
          action_client(json_render(response)),
        ]
      }
  }
}

///|
/// Drive one line coming from the agent (client side).
pub fn client_line(
  session : Session,
  line : String,
  now : Int64,
) -> Array[Json] {
  let value = match json_parse_opt(line) {
    Some(value) => value
    None => return protocol_error(session, now, "invalid JSON", line)
  }
  match parse_rpc(value) {
    None => protocol_error(session, now, "not a JSON-RPC message", line)
    Some(msg) => handle_client_message(session, msg, line, now)
  }
}

///|
/// Whether a tool should appear in `tools/list`.
///
/// Hiding is conservative: a tool is hidden only when an unconditional deny
/// rule matches it, or when nothing matches and the default is deny. Tools
/// governed by conditional rules stay visible, because those rules can allow
/// some arguments.
pub fn advertise_tool(policy : Policy, tool : String) -> Bool {
  let mut conditional = false
  for rule in policy.rules {
    if glob_match(rule.tool, tool) {
      if rule.checks.is_empty() {
        return rule.decision != "deny"
      }
      // A conditional *deny* narrows what is allowed; it never grants a
      // capability, so it must not keep an otherwise denied tool visible.
      if rule.decision != "deny" {
        conditional = true
      }
    }
  }
  if conditional {
    return true
  }
  policy.default_decision != "deny"
}

///|
fn filter_tool_list(session : Session, result : Json) -> (Json, Int32) {
  let fields = match json_as_object(result) {
    Some(fields) => fields
    None => return (result, 0)
  }
  let tools = match fields.get("tools") {
    Some(Array(tools)) => tools
    _ => return (result, 0)
  }
  let kept : Array[Json] = []
  let mut hidden = 0
  for tool in tools {
    let name = match json_get(tool, "name") {
      Some(String(name)) => name
      _ => ""
    }
    if name != "" && advertise_tool(session.policy, name) {
      kept.push(tool)
    } else {
      hidden += 1
    }
  }
  if hidden == 0 {
    return (result, 0)
  }
  let rewritten : Map[String, Json] = Map([])
  for key, value in fields {
    rewritten.set(key, value)
  }
  rewritten.set("tools", json_arr(kept))
  (Json::object(rewritten), hidden)
}

///|
fn handle_server_response(
  session : Session,
  msg : RpcMessage,
  line : String,
  now : Int64,
) -> Array[Json] {
  let key = id_key(msg.id)
  if session.lists.contains(key) {
    let _ = session.lists.remove(key)
    let actions : Array[Json] = []
    match msg.result {
      Some(result) => {
        let (rewritten, hidden) = filter_tool_list(session, result)
        if hidden > 0 {
          actions.push(
            action_client(json_render(result_response(msg.id, rewritten))),
          )
          actions.push(
            ledger_event(session, now, "tool_list_filtered", [
              ("hidden", json_int(hidden)),
            ]),
          )
        } else {
          actions.push(action_client(line))
        }
      }
      None => actions.push(action_client(line))
    }
    return actions
  }
  if session.inflight.contains(key) {
    let call = match session.inflight.get(key) {
      Some(call) => call
      None => return [action_client(line)]
    }
    let _ = session.inflight.remove(key)
    let texts = collect_strings(
      match msg.result {
        Some(result) => result
        None => msg.error.unwrap_or(Json::null())
      },
    )
    let raws = if call.sensitive {
      scan_raw(texts, session.policy.min_secret_len)
    } else {
      []
    }
    let added = if call.sensitive {
      remember_raw(session, call.call, call.tool, raws)
    } else {
      0
    }
    let masked = session.policy.mask_secrets && raws.length() > 0
    let outgoing = if masked {
      json_render(map_json_strings(msg.raw, fn(text) { mask_text(text, raws) }))
    } else {
      line
    }
    let is_error = match msg.error {
      Some(_) => true
      None => false
    }
    let actions : Array[Json] = [
      action_client(outgoing),
      ledger_event(session, now, "result", [
        ("tool", json_str(call.tool)),
        ("call", json_str(call.call)),
        ("bytes", json_int(line.length())),
        ("is_error", json_bool(is_error)),
        ("sensitive", json_bool(call.sensitive)),
        ("new_values", json_int(added)),
        ("masked", json_int(if masked { raws.length() } else { 0 })),
      ]),
    ]
    actions
  } else {
    // Response to a request the gateway did not mediate (for example a
    // server-initiated `roots/list` answered by the client). Forward as-is.
    [action_client(line)]
  }
}

///|
fn handle_server_request(
  session : Session,
  msg : RpcMessage,
  line : String,
  now : Int64,
) -> Array[Json] {
  let verb = match msg.verb {
    Some(verb) => verb
    None => ""
  }
  if verb == "roots/list" {
    [
      action_upstream(line),
      ledger_event(session, now, "server_request", [
        ("method", json_str(verb)),
        ("decision", json_str("allow")),
      ]),
    ]
  } else {
    let response = error_response(
      msg.id,
      -32001,
      "MoonGate: server-initiated \{verb} is not permitted",
      None,
    )
    [
      ledger_event(session, now, "server_request", [
        ("method", json_str(verb)),
        ("decision", json_str("deny")),
      ]),
      action_upstream(json_render(response)),
    ]
  }
}

///|
/// Drive one line coming from the upstream MCP server.
pub fn server_line(
  session : Session,
  line : String,
  now : Int64,
) -> Array[Json] {
  let value = match json_parse_opt(line) {
    Some(value) => value
    None => {
      let actions : Array[Json] = []
      actions.push(
        ledger_event(session, now, "protocol_error", [
          ("side", json_str("server")),
          ("message", json_str("invalid JSON")),
          ("bytes", json_int(line.length())),
        ]),
      )
      actions.push(
        action_notice(
          "warn", "MoonGate dropped a malformed line from the upstream server",
        ),
      )
      return actions
    }
  }
  match parse_rpc(value) {
    None =>
      [
        action_notice(
          "warn", "MoonGate dropped a non JSON-RPC line from upstream",
        ),
      ]
    Some(msg) =>
      match msg.verb {
        Some(verb) =>
          if msg.has_id {
            handle_server_request(session, msg, line, now)
          } else {
            [
              action_upstream(line),
              ledger_event(session, now, "server_notification", [
                ("method", json_str(verb)),
              ]),
            ]
          }
        None => handle_server_response(session, msg, line, now)
      }
  }
}

///|
/// Stateless policy check for the in-process guard.
///
/// The guard runs inside the MCP server, where there is no session, no
/// lineage table and no approval queue. It therefore enforces only hard
/// denials: `approval` is reported as `allow` because the gateway decides
/// whether a human approved the call. This is the second enforcement point —
/// if the gateway is bypassed, forbidden tools still do not run.
pub fn guard_decision(policy : Policy, tool : String, args : Json) -> Json {
  if !placeholders_in(args).is_empty() {
    return guard_payload(
      "deny", "unresolved-secret", "a placeholder can only be resolved by the gateway",
    )
  }
  match match_rule(policy, tool, args) {
    Some(hit) => guard_payload(hit.decision, hit.id, hit.reason)
    None => guard_payload(policy.default_decision, "default", "no rule matched")
  }
}

///|
fn guard_payload(decision : String, rule : String, reason : String) -> Json {
  let effective = if decision == "deny" { "deny" } else { "allow" }
  json_obj([
    ("decision", json_str(effective)),
    ("policy_decision", json_str(decision)),
    ("rule", json_str(rule)),
    ("reason", json_str(reason)),
  ])
}

///|
/// Resolve a pending approval. Approving forwards the original request, so the
/// upstream server sees exactly one call; denying answers the client with a
/// policy error. Either way the pending entry is removed, which is what makes
/// a grant single-use.
pub fn resolve_approval(
  session : Session,
  call : String,
  approved : Bool,
  now : Int64,
) -> Array[Json] {
  let pending = match session.pending.get(call) {
    Some(pending) => pending
    None => return [action_notice("warn", "MoonGate: unknown approval \{call}")]
  }
  let _ = session.pending.remove(call)
  let actions : Array[Json] = []
  if now >= pending.expires {
    session.denied += 1
    let response = error_response(
      pending.id,
      -32002,
      "MoonGate: approval for \{pending.tool} expired",
      Some(
        json_obj([
          (
            "moongate",
            json_obj([
              ("decision", json_str("approval_timeout")),
              ("call", json_str(pending.call)),
              ("rule", json_str(pending.rule)),
            ]),
          ),
        ]),
      ),
    )
    actions.push(action_client(json_render(response)))
    actions.push(
      ledger_event(session, now, "approval", [
        ("call", json_str(pending.call)),
        ("tool", json_str(pending.tool)),
        ("decision", json_str("timeout")),
      ]),
    )
    return actions
  }
  if approved {
    if session.policy.budget.max_reveals > 0 &&
      session.reveal_count + pending.revealed >
      session.policy.budget.max_reveals {
      session.denied += 1
      let response = error_response(
        pending.id,
        -32001,
        "MoonGate: reveal budget exhausted",
        Some(
          json_obj([
            (
              "moongate",
              json_obj([
                ("decision", json_str("budget-reveals")),
                ("call", json_str(pending.call)),
              ]),
            ),
          ]),
        ),
      )
      actions.push(action_client(json_render(response)))
      actions.push(
        ledger_event(session, now, "approval", [
          ("call", json_str(pending.call)),
          ("tool", json_str(pending.tool)),
          ("decision", json_str("budget-denied")),
        ]),
      )
      return actions
    }
    session.reveal_count += pending.revealed
    session.allowed += 1
    session.forwarded += 1
    session.inflight.set(pending.key, {
      call: pending.call,
      tool: pending.tool,
      sensitive: pending.sensitive,
    })
    actions.push(action_upstream(pending.line))
    actions.push(
      json_obj([
        ("kind", json_str("approval_resolved")),
        ("call", json_str(pending.call)),
        ("approved", json_bool(true)),
      ]),
    )
    actions.push(
      ledger_event(session, now, "approval", [
        ("call", json_str(pending.call)),
        ("tool", json_str(pending.tool)),
        ("decision", json_str("granted")),
        ("digest", json_str(pending.digest)),
      ]),
    )
  } else {
    session.denied += 1
    let response = error_response(
      pending.id,
      -32001,
      "MoonGate: operator denied \{pending.tool}",
      Some(
        json_obj([
          (
            "moongate",
            json_obj([
              ("decision", json_str("approval_denied")),
              ("call", json_str(pending.call)),
              ("rule", json_str(pending.rule)),
            ]),
          ),
        ]),
      ),
    )
    actions.push(action_client(json_render(response)))
    actions.push(
      json_obj([
        ("kind", json_str("approval_resolved")),
        ("call", json_str(pending.call)),
        ("approved", json_bool(false)),
      ]),
    )
    actions.push(
      ledger_event(session, now, "approval", [
        ("call", json_str(pending.call)),
        ("tool", json_str(pending.tool)),
        ("decision", json_str("denied")),
      ]),
    )
  }
  actions
}

///|
/// Final ledger event: the counters the report reconciles against. Writing
/// them into the chain makes truncation detectable even without replaying the
/// whole run.
pub fn close_session(session : Session, now : Int64) -> Json {
  ledger_event(session, now, "session_close", [
    ("allowed", json_int(session.allowed)),
    ("denied", json_int(session.denied)),
    ("approval_requests", json_int(session.approval_requests)),
    ("forwarded", json_int(session.forwarded)),
    ("tainted_values", json_int(session.tainted_values)),
    ("total_calls", json_int(session.total_calls)),
    ("arg_bytes", json_i64(session.arg_bytes)),
    ("reveal_count", json_int(session.reveal_count)),
  ])
}

///|
/// Expire pending approvals. The host calls this on a timer; expired requests
/// get a policy error so the agent is never left waiting forever.
pub fn expire_approvals(session : Session, now : Int64) -> Array[Json] {
  let expired : Array[String] = []
  for call, pending in session.pending {
    if now >= pending.expires {
      expired.push(call)
    }
  }
  let actions : Array[Json] = []
  for call in expired {
    for action in resolve_approval(session, call, false, now) {
      actions.push(action)
    }
  }
  actions
}