///|
using @directory {
  type ToolContributionRegistry,
  type ToolEntry,
  type ToolNameIndex,
  build_catalog_from_defs,
  nonempty_provenance,
  tool_collision_message,
}

///|
/// Project one directory fact to observers. This is an Agent-owned composition
/// fact, not a reducer/journal fact, so it carries no run scope and names the
/// operation explicitly when it has one.
fn emit_tool_catalog_change(
  observers : Array[&@port.Observer],
  change : @directory.ToolCatalogChange,
) -> Unit {
  let payload : @types.ToolCatalogChangedPayload = match change {
    Published(owner~, revision~, tools~) =>
      {
        change: @types.ToolCatalogChange::Published,
        owner,
        revision: Some(revision),
        catalog_version: None,
        tool_count: tools.length(),
        operation_id: None,
        detail: None,
      }
    Withdrawn(owner~, revision~, mode~) =>
      {
        change: @types.ToolCatalogChange::Withdrawn,
        owner,
        revision: Some(revision),
        catalog_version: None,
        tool_count: 0,
        operation_id: None,
        detail: Some("mode=" + mode),
      }
    CleanupFailed(owner~, revision~, detail=_) =>
      {
        change: @types.ToolCatalogChange::CleanupFailed,
        owner,
        revision: Some(revision),
        catalog_version: None,
        tool_count: 0,
        operation_id: None,
        detail: Some("retire failed"),
      }
  }
  let event : @types.TurnEvent = ToolCatalogChanged(payload~)
  for observer in observers {
    observer.on_event(None, @event_snapshot.snapshot_turn_event(event))
  }
}

///|
/// Project an operation-level capture fact: which catalog version this
/// operation installed and how many tools it covers.
fn emit_catalog_captured(
  observers : Array[&@port.Observer],
  operation_id : String,
  catalog_version : Int32,
  tool_count : Int32,
) -> Unit {
  let payload : @types.ToolCatalogChangedPayload = {
    change: @types.ToolCatalogChange::Captured,
    owner: "",
    revision: None,
    catalog_version: Some(catalog_version),
    tool_count,
    operation_id: Some(operation_id),
    detail: None,
  }
  let event : @types.TurnEvent = ToolCatalogChanged(payload~)
  for observer in observers {
    observer.on_event(None, @event_snapshot.snapshot_turn_event(event))
  }
}

///|
/// Capture the directory into the Puppet at operation admission and take the
/// new version lease. A capture that changed nothing keeps the installed
/// bindings and their versions instead of re-capturing.
fn AgentRuntime::capture_tool_contributions(
  self : AgentRuntime,
  operation_id? : String,
) -> Unit raise @error.AgentError {
  if self.tool_contributions.is_closed() {
    return
  }
  let candidate = self.tool_contributions.prepare_capture(
    CatalogVersion(self.next_catalog_version),
    self.hooks,
  ) catch {
    error =>
      raise @error.AgentError::Runtime(
        @error.RuntimeError::InvocationFailed(
          "tool catalog: " +
          @diagnostic.bounded_error_label(error.to_string(), 256),
        ),
      )
  }
  guard candidate is Some(capture) else { return }
  if !self.puppet.replace_catalog(capture.catalog) {
    raise @error.AgentError::Runtime(
      @error.RuntimeError::InvocationFailed(
        "tool catalog: a run is active at operation admission",
      ),
    )
  }
  let catalog_version = self.next_catalog_version
  self.tool_bindings.val = capture.bindings
  self.binding_versions.val = capture.binding_versions()
  self.versions.install_captured(capture.versions())
  self.next_catalog_version += 1
  self.tool_contributions.accept_capture(capture)
  match operation_id {
    Some(id) =>
      emit_catalog_captured(
        self.observers,
        id,
        catalog_version,
        capture.catalog.definitions().length(),
      )
    None => ()
  }
}

///|
/// Retain the installed capture's versions once for the admitted operation.
fn AgentRuntime::acquire_operation_versions(self : AgentRuntime) -> Unit {
  self.versions.acquire(self.tool_contributions)
}

///|
/// Give the admitted operation's lease back. When an execution's termination is
/// still unconfirmed the lease is retained instead: an unknown execution keeps
/// the version — and therefore its resources — alive.
fn AgentRuntime::release_operation_versions(self : AgentRuntime) -> Unit {
  self.versions.release_operation(
    self.tool_contributions,
    self.tool_runtime.has_unconfirmed(),
  )
}

///|
/// Release retained leases once every unconfirmed execution has a terminal
/// disposition. Called after runtime cleanup confirms that state, never before.
fn AgentRuntime::release_retained_versions(self : AgentRuntime) -> Unit {
  self.versions.release_retained(
    self.tool_contributions,
    self.tool_runtime.has_unconfirmed(),
  )
}

///|
/// Retire versions that lost every reference, then retry pending ones. Never
/// raises: a retire failure is reported as a secondary diagnostic and stays
/// pending for the next attempt, so it can never mask the primary outcome.
async fn AgentRuntime::retire_released_versions(
  self : AgentRuntime,
) -> Unit noraise {
  @async.protect_from_cancel(async fn() noraise {
    self.release_operation_versions()
    self.release_retained_versions()
    self.retire_pending_versions()
  }) catch {
    error =>
      emit_secondary_failure(
        self.observers,
        "tool_version_finalizer",
        error.to_string(),
      )
  }
}

///|
/// Retry the pending-cleanup set without touching the current lease.
async fn AgentRuntime::retire_pending_versions(
  self : AgentRuntime,
) -> Unit noraise {
  let failures = self.tool_contributions.retire_unreferenced()
  for failure in failures {
    emit_secondary_failure(
      self.observers,
      "tool_version_retire",
      "version=\{failure.version} owner=\{failure.owner} revision=\{failure.revision}: retire failed",
    )
  }
}