Skip to main content

lib/skuld/effects/port.ex

defmodule Skuld.Effects.Port do
  @moduledoc """
  Effect for dispatching function calls to pluggable backends — both plain
  (blocking) and effectful (computation-returning).

  Part of the `skuld_port` package, which provides the Port dispatch effect,
  EffectfulFacade for typed contracts, Adapter for bridging effectful and
  plain code, and Command/Transaction for operations. See the
  [architecture guide](https://hexdocs.pm/skuld/architecture.html)
  for how these fit into the Skuld ecosystem.

  This effect lets domain code express "call this function" without binding to a
  particular implementation. Each request specifies:

    * `mod` – module identity (contract or implementation module)
    * `name` – function name
    * `args` – list of positional arguments

  ## Use Cases

  Port works with any external code, whether plain or effectful:

    * Database queries, HTTP API calls, file system operations
    * External service integrations and legacy code
    * Effectful implementations behind typed contracts — decompose a large
      effect system into swappable cells with compile-time verified boundaries

  ## Result Tuple Convention (plain resolvers)

  Plain handlers should return `{:ok, value}` or `{:error, reason}` tuples.
  This convention enables two request modes:

    * `request/3` – returns the result tuple as-is for caller to handle
    * `request!/3` – unwraps `{:ok, value}` or dispatches `Throw` on error

  Effectful resolvers return computations that are inlined into the current
  effect context — no result tuple convention applies.

  ## Example

      alias Skuld.Effects.Port

      # Plain implementation
      defmodule MyApp.UserQueries do
        def find_by_id(id) do
          case Repo.get(User, id) do
            nil -> {:error, {:not_found, User, id}}
            user -> {:ok, user}
          end
        end
      end

      # Using request/3 - returns result tuple
      defcomp find_user(id) do
        result <- Port.request(MyApp.UserQueries, :find_by_id, [id])
        case result do
          {:ok, user} -> user
          {:error, _} -> nil
        end
      end

      # Using request!/3 - unwraps or throws
      defcomp find_user!(id) do
        user <- Port.request!(MyApp.UserQueries, :find_by_id, [id])
        user
      end

      # Runtime: dispatch to actual modules
      find_user!(123)
      |> Port.with_handler(%{MyApp.UserQueries => :direct})
      |> Throw.with_handler()
      |> Comp.run!()

      # Test: stub responses with exact key matching
      find_user!(123)
      |> Port.with_test_handler(%{
        Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{id: 123, name: "Alice"}}
      })
      |> Throw.with_handler()
      |> Comp.run!()

      # Test: function-based handler with pattern matching
      find_user!(123)
      |> Port.with_fn_handler(fn
        MyApp.UserQueries, :find_by_id, [id] -> {:ok, %{id: id, name: "User \#{id}"}}
      end)
      |> Throw.with_handler()
      |> Comp.run!()
  """

  @behaviour Skuld.Comp.IHandle
  @behaviour Skuld.Comp.IInstall

  use Skuld.Comp.DefOp

  alias Skuld.Comp
  alias Skuld.Comp.Env
  alias Skuld.Comp.Throw, as: ThrowResult
  alias Skuld.Comp.Types
  alias Skuld.Effects.Throw
  # Single atom key for all Port state — sig() is __MODULE__ (a plain atom),
  # faster than tuple keys in map lookups.
  @state_key "Elixir.Skuld.Effects.Port"

  #############################################################################
  ## Effect State
  #############################################################################

  defmodule State do
    @moduledoc false
    @enforce_keys [:registry]
    defstruct [:registry, :log, :handler_state]

    @type t :: %__MODULE__{
            registry: Skuld.Effects.Port.registry(),
            log: list() | nil,
            handler_state: term()
          }

    @doc false
    @spec new(Skuld.Effects.Port.registry()) :: t()
    def new(registry), do: %__MODULE__{registry: registry}

    @doc false
    @spec new(Skuld.Effects.Port.registry(), atom() | nil) :: t()
    def new(registry, log), do: %__MODULE__{registry: registry, log: log}
  end

  #############################################################################
  ## Operations (generated by def_op)
  #############################################################################

  def_op(request(mod, name, args))

  @request_op @__request_op__

  #############################################################################
  ## Types
  #############################################################################

  @typedoc "Module identity (contract or implementation module)"
  @type port_module :: module()

  @typedoc "Function exported by `port_module`"
  @type port_name :: atom()

  @typedoc "List of positional arguments"
  @type args :: list()

  @typedoc """
  Registry entry for dispatching requests.

  ## Runtime resolvers (used with `with_handler/3`)

    * `:direct` – call `apply(mod, name, args)`, result is a plain value
    * `module` – invokes `apply(module, name, args)` (implementation module).
      Modules where `__port_effectful__?/0` returns truthy (e.g. via
      `use MyContract.Effectful`) are auto-detected as effectful resolvers
      whose return values are computations inlined into the current effect
      context. Returning `false` opts out of auto-detection.
    * `{:effectful, module}` – explicit effectful resolver (same as above,
      for backward compatibility or modules without the marker)
    * `function` (arity 3) – `fun.(mod, name, args)`
    * `{module, function}` – invokes `apply(module, function, [mod, name, args])`

  ## Default resolvers (used as `:__default__` catch-all)

    * `{:test_stub, responses}` – map-based test stubs keyed by `Port.key/3`
    * `{:test_stub, responses, fallback}` – test stubs with fallback function
    * `{:fn_dispatch, handler_fn}` – function-based dispatch via `fn(mod, name, args)`
    * `{:stateful_dispatch, handler_fn}` – stateful dispatch via
      `fn(mod, name, args, state) -> {result, new_state}`
  """
  @type resolver ::
          :direct
          | {:effectful, module()}
          | (port_module(), port_name(), args() -> term())
          | {module(), atom()}
          | module()
          | {:test_stub, map()}
          | {:test_stub, map(), fn_handler()}
          | {:fn_dispatch, fn_handler()}
          | {:stateful_dispatch, stateful_handler()}

  @typedoc "Registry mapping port modules (or `:__default__`) to resolvers"
  @type registry :: %{(port_module() | :__default__) => resolver()}

  @typedoc "Function handler for test scenarios - receives (mod, name, args)"
  @type fn_handler :: (port_module(), port_name(), args() -> term())

  @typedoc "Stateful handler function - receives (mod, name, args, state), returns {result, new_state}"
  @type stateful_handler :: (port_module(), port_name(), args(), term() -> {term(), term()})

  # Sentinel registry key for catch-all resolvers (test stubs, fn handlers)
  @default_key :__default__

  #############################################################################
  ## Operations
  #############################################################################

  @doc """
  Build a request for the given module/function.

  Returns the result tuple `{:ok, value}` or `{:error, reason}` as-is,
  allowing the caller to handle errors explicitly.

  ## Example

      Port.request(MyApp.UserQueries, :find_by_id, [123])
      # => {:ok, %User{...}} or {:error, {:not_found, User, 123}}
  """
  @spec request(port_module(), port_name()) :: Types.computation()
  def request(mod, name), do: request(mod, name, [])

  @doc """
  Build a request that unwraps the result or throws on error.

  Expects the handler to return `{:ok, value}` or `{:error, reason}`.
  On success, returns the unwrapped `value`. On error, dispatches a
  `Skuld.Effects.Throw` effect with the `reason`.

  Requires a `Throw.with_handler/1` in the handler chain.

  ## Example

      Port.request!(MyApp.UserQueries, :find_by_id, [123])
      # => %User{...} or throws {:not_found, User, 123}
  """
  @spec request!(port_module(), port_name(), args()) :: Types.computation()
  def request!(mod, name, args \\ []) do
    Comp.bind(request(mod, name, args), fn
      {:ok, value} -> value
      {:error, reason} -> Throw.throw(reason)
    end)
  end

  @doc """
  Build a request that applies a custom unwrap function, then unwraps or throws.

  The `unwrap_fn` receives the raw result from the handler and must return
  `{:ok, value}` or `{:error, reason}`. The result is then handled like
  `request!/3`: success values are unwrapped, errors dispatch `Throw`.

  This is used by `Port.Contract` when `bang:` is set to a custom function.

  Requires a `Throw.with_handler/1` in the handler chain.

  ## Example

      Port.request_bang(MyApp.Users, :find_by_id, [123], fn
        nil -> {:error, :not_found}
        user -> {:ok, user}
      end)
      # => %User{...} or throws :not_found
  """
  @spec request_bang(port_module(), port_name(), args(), (term() ->
                                                            {:ok, term()} | {:error, term()})) ::
          Types.computation()
  def request_bang(mod, name, args, unwrap_fn) do
    Comp.bind(request(mod, name, args), fn result ->
      case unwrap_fn.(result) do
        {:ok, value} -> value
        {:error, reason} -> Throw.throw(reason)
      end
    end)
  end

  #############################################################################
  ## Key Generation (for test stubs)
  #############################################################################

  @doc """
  Build a canonical key usable with `with_test_handler/2`.

  Arguments are normalized to produce consistent keys.

  ## Example

      Port.key(MyApp.UserQueries, :find_by_id, [123])
  """
  @spec key(port_module(), port_name(), args()) ::
          {port_module(), port_name(), binary()}
  def key(mod, name, args) do
    {mod, name, normalize_args(args)}
  end

  @doc false
  @spec normalize_args(term()) :: binary()
  def normalize_args(args) do
    args
    |> canonical_term()
    |> :erlang.term_to_binary()
  end

  defp canonical_term(%_{} = struct) do
    # Convert struct to list of {key, value} pairs including __struct__
    struct_name = struct.__struct__

    struct
    |> Map.from_struct()
    |> Enum.map(fn {k, v} -> {k, canonical_term(v)} end)
    |> Enum.concat([{:__struct__, struct_name}])
    |> Enum.sort_by(&elem(&1, 0))
  end

  defp canonical_term(map) when is_map(map) do
    map
    |> Enum.map(fn {k, v} -> {k, canonical_term(v)} end)
    |> Enum.sort_by(&elem(&1, 0))
  end

  defp canonical_term(list) when is_list(list) do
    if Keyword.keyword?(list) do
      # Keyword list - sort by key for canonical form
      list
      |> Enum.map(fn {k, v} -> {k, canonical_term(v)} end)
      |> Enum.sort_by(&elem(&1, 0))
    else
      # Regular list - preserve order
      Enum.map(list, &canonical_term/1)
    end
  end

  defp canonical_term(tuple) when is_tuple(tuple) do
    {:__tuple__, tuple |> Tuple.to_list() |> Enum.map(&canonical_term/1)}
  end

  defp canonical_term(other), do: other

  #############################################################################
  ## Handler Installation - Runtime
  #############################################################################

  @doc """
  Install a scoped Port handler for a computation.

  Pass a registry map keyed by module to control how requests are
  dispatched. Each entry can be one of:

    * `:direct` – call `apply(mod, name, args)`, returns a plain value
    * `module` – invokes `apply(module, name, args)`. Modules where
      `__port_effectful__?/0` returns truthy (e.g. via
      `use MyContract.Effectful`) are auto-detected as effectful resolvers
      whose return values are computations inlined into the current effect
      context. Returning `false` opts out of auto-detection.
    * `{:effectful, module}` – explicit effectful resolver (same as above,
      for backward compatibility or modules without the marker)
    * `function` (arity 3) – `fun.(mod, name, args)`
    * `{module, function}` – invokes `apply(module, function, [mod, name, args])`

  Plain resolvers (`:direct`, function, `{mod, fun}`, module) return values
  that are passed directly to the continuation. Effectful resolvers (auto-
  detected or explicit `{:effectful, mod}`) return computations that
  participate in the surrounding effect context.

  ## Nested Handlers

  Nested `with_handler` calls **merge** registries rather than shadowing.
  Inner entries win on conflict. When the inner scope exits, the previous
  registry is restored.

      # Outer registers ModuleA, inner adds ModuleB — both are available
      my_comp
      |> Port.with_handler(%{ModuleB => :direct})   # inner: adds ModuleB
      |> Port.with_handler(%{ModuleA => :direct})   # outer: registers ModuleA
      |> Comp.run!()

  Note: `with_test_handler` and `with_fn_handler` do **not** merge with
  runtime registries — they replace the dispatch mode entirely, which is
  the expected behaviour for test stubs.

  ## Options

    * `:log` — when truthy, enables dispatch logging. Every Port dispatch
      records a `{mod, name, args, result}` 4-tuple in `Port.State.log`.
      Disabled by default (nil) for zero overhead in production.
    * `:output` — transform function `(result, %Port.State{}) -> output`
      called on scope exit. When logging is enabled, `state.log` contains
      the log entries in chronological order.

  ## Example

      # Plain implementations
      my_comp
      |> Port.with_handler(%{
        MyApp.UserQueries => :direct,
        MyApp.Repository => MyApp.Repository.Ecto
      })
      |> Comp.run!()

      # Effectful implementation — auto-detected via __port_effectful__?/0
      my_comp
      |> Port.with_handler(%{
        MyApp.Repository => MyApp.Repository.EffectfulImpl
      })
      |> Throw.with_handler()
      |> Comp.run!()

      # With dispatch logging in tests
      {result, log} =
        my_comp
        |> Port.with_handler(
          %{MyApp.Repo => MyApp.Repo.Test},
          log: true,
          output: fn result, state -> {result, state.log} end
        )
        |> Throw.with_handler()
        |> Comp.run!()
  """
  @spec with_handler(Types.computation(), registry(), keyword()) :: Types.computation()
  def with_handler(comp, registry \\ %{}, opts \\ []) do
    install_registry(comp, registry, opts)
  end

  @doc """
  Install Port handler via catch clause syntax.

  Config is the registry map, or `{registry, opts}`:

      catch
        Port -> %{MyModule => :direct}
        Port -> {%{MyModule => :direct}, output: fn r, s -> {r, s} end}
  """
  @impl Skuld.Comp.IInstall
  def __handle__(comp, {registry, opts}) when is_map(registry) and is_list(opts),
    do: with_handler(comp, registry, opts)

  def __handle__(comp, registry) when is_map(registry), do: with_handler(comp, registry)

  #############################################################################
  ## Handler Installation - Test (Map-based)
  #############################################################################

  @doc """
  Install a test handler with canned responses.

  Provide a map of responses keyed by `Port.key/3`. Missing keys will
  throw `{:port_not_stubbed, key}` unless a `fallback:` function is provided.

  ## Options

    * `:fallback` - A function `(mod, name, args) -> result` to call when
      no exact key match is found. Useful for handling dynamic arguments
      while still using exact matching for known cases.
    * `:log` — enable dispatch logging (see `with_handler/3`)
    * `:output` - Transform `(result, %Port.State{}) -> output` on scope exit

  ## Example

      responses = %{
        Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{name: "Alice"}},
        Port.key(MyApp.UserQueries, :find_by_id, [456]) => {:error, :not_found}
      }

      my_comp
      |> Port.with_test_handler(responses)
      |> Throw.with_handler()
      |> Comp.run!()

      # With fallback for dynamic cases
      my_comp
      |> Port.with_test_handler(responses, fallback: fn
        MyApp.AuditQueries, _name, _args -> :ok
        mod, name, args -> raise "Unhandled: \#{inspect(mod)}.\#{name}(\#{inspect(args)})"
      end)
      |> Throw.with_handler()
      |> Comp.run!()
  """
  @spec with_test_handler(Types.computation(), map(), keyword()) :: Types.computation()
  def with_test_handler(comp, responses, opts \\ []) when is_map(responses) do
    fallback = Keyword.get(opts, :fallback)

    resolver =
      if fallback do
        {:test_stub, responses, fallback}
      else
        {:test_stub, responses}
      end

    install_registry(comp, %{@default_key => resolver}, opts)
  end

  #############################################################################
  ## Handler Installation - Test (Function-based)
  #############################################################################

  @doc """
  Install a function-based test handler.

  The handler function receives `(mod, name, args)` and can use Elixir's
  full pattern matching power including guards, pins, and wildcards.

  If no function clause matches, throws `{:port_not_handled, mod, name, args}`.

  ## Example

      handler = fn
        # Pin specific values
        MyApp.UserQueries, :find_by_id, [^expected_id] ->
          {:ok, %{id: expected_id, name: "Expected"}}

        # Match any value with wildcard
        MyApp.UserQueries, :find_by_id, [_any_id] ->
          {:ok, %{id: "default", name: "Default"}}

        # Match with guards
        MyApp.Queries, :paginate, [_query, limit] when limit > 100 ->
          {:error, :limit_too_high}

        # Match specific module, any function
        MyApp.AuditQueries, _function, _args ->
          :ok

        # Catch-all (optional)
        mod, fun, args ->
          raise "Unhandled: \#{inspect(mod)}.\#{fun}(\#{inspect(args)})"
      end

      my_comp
      |> Port.with_fn_handler(handler)
      |> Throw.with_handler()
      |> Comp.run!()

  ## Property-Based Testing

  Function handlers are ideal for property-based tests where exact values
  aren't known upfront:

      property "user lookup succeeds" do
        check all user_id <- uuid_generator() do
          handler = fn
            UserQueries, :find_by_id, [^user_id] ->
              {:ok, %{id: user_id, name: "Test User"}}
          end

          result =
            find_user(user_id)
            |> Port.with_fn_handler(handler)
            |> Throw.with_handler()
            |> Comp.run!()

          assert {:ok, _} = result
        end
      end
  """
  @spec with_fn_handler(Types.computation(), fn_handler(), keyword()) :: Types.computation()
  def with_fn_handler(comp, handler_fn, opts \\ []) when is_function(handler_fn, 3) do
    install_registry(comp, %{@default_key => {:fn_dispatch, handler_fn}}, opts)
  end

  #############################################################################
  ## Handler Installation - Test (Stateful Function-based)
  #############################################################################

  @doc """
  Install a stateful function-based test handler.

  The handler function receives `(mod, name, args, state)` and returns
  `{result, new_state}`. State is threaded across Port calls within the
  scope, enabling test doubles where writes are visible to subsequent reads.

  ## Options

    * `:log` — enable dispatch logging (see `with_handler/3`)
    * `:output` — transform `(result, %Port.State{}) -> output` on scope exit.
      The `state.handler_state` field contains the final handler state.

  ## Example

      # Stateful in-memory store: insert then read back
      handler = fn
        MyRepo, :insert, [record], state ->
          key = {record.__struct__, record.id}
          {{:ok, record}, Map.put(state, key, record)}

        MyRepo, :get, [schema, id], state ->
          key = {schema, id}
          {Map.get(state, key), state}
      end

      {result, final_state} =
        my_insert_then_read_comp
        |> Port.with_stateful_handler(%{}, handler,
          output: fn result, state -> {result, state.handler_state} end
        )
        |> Throw.with_handler()
        |> Comp.run!()

  ## Property-Based Testing

  Stateful handlers pair naturally with property-based tests where
  the in-memory model serves as the oracle:

      property "insert then get returns the same record" do
        check all id <- integer(), name <- string(:alphanumeric) do
          record = %User{id: id, name: name}
          handler = fn
            Repo, :insert, [r], state -> {{:ok, r}, Map.put(state, {User, r.id}, r)}
            Repo, :get, [schema, id], state -> {Map.get(state, {schema, id}), state}
          end

          result =
            insert_then_get(record)
            |> Port.with_stateful_handler(%{}, handler)
            |> Throw.with_handler()
            |> Comp.run!()

          assert result == record
        end
      end
  """
  @spec with_stateful_handler(Types.computation(), term(), stateful_handler(), keyword()) ::
          Types.computation()
  def with_stateful_handler(comp, initial_state, handler_fn, opts \\ [])
      when is_function(handler_fn, 4) do
    install_registry(
      comp,
      %{@default_key => {:stateful_dispatch, handler_fn}},
      Keyword.put(opts, :initial_handler_state, initial_state)
    )
  end

  #############################################################################
  ## Shared Registry Installation
  #############################################################################

  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  defp install_registry(comp, registry, opts) do
    output = Keyword.get(opts, :output)
    suspend = Keyword.get(opts, :suspend)
    log_enabled = Keyword.has_key?(opts, :log) and opts[:log] != nil
    initial_handler_state = Keyword.get(opts, :initial_handler_state)
    state_key = @state_key

    comp
    |> Comp.scoped(fn env ->
      previous = Env.get_state(env, state_key)

      # Auto-detect effectful resolvers: plain module atoms where
      # __port_effectful__?/0 returns truthy are wrapped as {:effectful, module}.
      resolved_registry =
        Map.new(registry, fn
          {key, module} when is_atom(module) and module not in [:direct] ->
            Code.ensure_loaded(module)

            if function_exported?(module, :__port_effectful__?, 0) and
                 module.__port_effectful__?() do
              {key, {:effectful, module}}
            else
              {key, module}
            end

          entry ->
            entry
        end)

      # Merge with any existing registry so nested handler installations
      # accumulate registrations rather than shadowing them.
      merged_registry =
        case previous do
          %State{registry: outer_registry} -> Map.merge(outer_registry, resolved_registry)
          _ -> resolved_registry
        end

      # Log is a list (enabled) or nil (disabled).
      # Inner :log option starts a fresh log; otherwise inherit outer's log.
      merged_log =
        if log_enabled do
          []
        else
          case previous do
            %State{log: outer_log} -> outer_log
            _ -> nil
          end
        end

      new_state = %State{
        registry: merged_registry,
        log: merged_log,
        handler_state: initial_handler_state
      }

      env_with_state = Env.put_state(env, state_key, new_state)

      # If :suspend option provided, compose into transform_suspend
      {modified_env, previous_transform} =
        if suspend do
          old_transform = Env.get_transform_suspend(env_with_state)

          new_transform = fn susp, e ->
            {susp1, e1} = old_transform.(susp, e)
            suspend.(susp1, e1)
          end

          {Env.with_transform_suspend(env_with_state, new_transform), old_transform}
        else
          {env_with_state, nil}
        end

      finally_k = fn value, e ->
        current_state = Env.get_state(e, state_key)

        # Restore previous state (or remove key if there was none).
        # Only propagate accumulated log entries to the outer scope when
        # this scope inherited the outer log (i.e. no fresh :log option).
        # When :log started a fresh log, entries stay in this scope only.
        restored_env =
          case previous do
            nil ->
              %{e | state: Map.delete(e.state, state_key)}

            %State{log: outer_log} when is_list(outer_log) and not log_enabled ->
              # Inherited outer log — carry forward accumulated entries.
              Env.put_state(e, state_key, %{previous | log: current_state.log})

            _ ->
              Env.put_state(e, state_key, previous)
          end

        # Restore previous transform_suspend if we modified it
        restored_env =
          if previous_transform do
            Env.with_transform_suspend(restored_env, previous_transform)
          else
            restored_env
          end

        # Output callback receives (value, state) — log is in state.log
        # reversed to chronological order.
        transformed_value =
          if output do
            output_state =
              case current_state do
                %State{log: log} when is_list(log) ->
                  %{current_state | log: Enum.reverse(log)}

                _ ->
                  current_state
              end

            output.(value, output_state)
          else
            value
          end

        {transformed_value, restored_env}
      end

      {modified_env, finally_k}
    end)
    |> Comp.with_handler(@__sig__, &__MODULE__.handle/3)
  end

  #############################################################################
  ## IHandler Implementation
  #############################################################################

  @impl Skuld.Comp.IHandle
  def handle({@request_op, mod, name, args}, env, k) do
    %State{registry: registry, log: log} = Env.get_state!(env, @state_key)

    # Look up module-specific resolver, fall back to :__default__, then error
    resolver =
      case Map.fetch(registry, mod) do
        {:ok, r} -> {:ok, r}
        :error -> Map.fetch(registry, @default_key)
      end

    case resolver do
      {:ok, {:test_stub, responses}} ->
        dispatch_test_stub(responses, nil, mod, name, args, log, env, k)

      {:ok, {:test_stub, responses, fallback}} ->
        dispatch_test_stub(responses, fallback, mod, name, args, log, env, k)

      {:ok, {:fn_dispatch, handler_fn}} ->
        dispatch_fn(handler_fn, mod, name, args, log, env, k)

      {:ok, {:stateful_dispatch, handler_fn}} ->
        dispatch_stateful(handler_fn, mod, name, args, log, env, k)

      {:ok, runtime_resolver} ->
        dispatch_runtime(runtime_resolver, mod, name, args, log, env, k)

      :error ->
        emit_log_and_return(
          %ThrowResult{error: {:unknown_port_module, mod}},
          mod,
          name,
          args,
          log,
          env
        )
    end
  end

  #############################################################################
  ## Dispatch Logic
  #############################################################################

  defp dispatch_runtime(resolver, mod, name, args, log, env, k) do
    case invoke(resolver, mod, name, args) do
      {:computation, comp} ->
        # Effectful resolver: inline the computation, capturing result for logging
        if log do
          Comp.call(comp, env, fn result, env2 ->
            k.(result, append_log(env2, mod, name, args, result))
          end)
        else
          Comp.call(comp, env, k)
        end

      {:value, result} ->
        emit_log_and_continue(result, mod, name, args, log, env, k)
    end
  rescue
    exception ->
      emit_log_and_return(
        %ThrowResult{error: {:port_failed, mod, name, exception}},
        mod,
        name,
        args,
        log,
        env
      )
  end

  defp dispatch_test_stub(responses, fallback, mod, name, args, log, env, k) do
    request_key = key(mod, name, args)

    case Map.fetch(responses, request_key) do
      {:ok, result} ->
        emit_log_and_continue(result, mod, name, args, log, env, k)

      :error when is_function(fallback, 3) ->
        # Try fallback function
        dispatch_fn(fallback, mod, name, args, log, env, k)

      :error ->
        # No match and no fallback
        emit_log_and_return(
          %ThrowResult{error: {:port_not_stubbed, request_key}},
          mod,
          name,
          args,
          log,
          env
        )
    end
  end

  defp dispatch_fn(handler_fn, mod, name, args, log, env, k) do
    result = handler_fn.(mod, name, args)
    emit_log_and_continue(result, mod, name, args, log, env, k)
  rescue
    e in FunctionClauseError ->
      # No matching clause - report what we received
      emit_log_and_return(
        %ThrowResult{error: {:port_not_handled, mod, name, args, e}},
        mod,
        name,
        args,
        log,
        env
      )

    e ->
      # Other error in handler
      emit_log_and_return(
        %ThrowResult{error: {:port_handler_error, mod, name, e}},
        mod,
        name,
        args,
        log,
        env
      )
  end

  defp dispatch_stateful(handler_fn, mod, name, args, log, env, k) do
    state_key = @state_key
    port_state = Env.get_state!(env, state_key)
    handler_state = port_state.handler_state
    {result, new_handler_state} = handler_fn.(mod, name, args, handler_state)
    # Unwrap DoubleDown.Dispatch.Defer — DD's fakes use Defer to
    # defer execution outside the NimbleOwnership lock. Skuld doesn't use
    # NimbleOwnership, so we invoke the deferred function immediately.
    result = unwrap_defer(result)
    updated_port_state = %{port_state | handler_state: new_handler_state}
    env_with_state = Env.put_state(env, state_key, updated_port_state)
    emit_log_and_continue(result, mod, name, args, log, env_with_state, k)
  rescue
    e in FunctionClauseError ->
      # No matching clause - report what we received
      emit_log_and_return(
        %ThrowResult{error: {:port_not_handled, mod, name, args, e}},
        mod,
        name,
        args,
        log,
        env
      )

    e ->
      # Other error in handler
      emit_log_and_return(
        %ThrowResult{error: {:port_handler_error, mod, name, e}},
        mod,
        name,
        args,
        log,
        env
      )
  end

  #############################################################################
  ## Defer Unwrapping
  #############################################################################

  # DoubleDown's stateful fakes (InMemory, OpenInMemory) use %Defer{} to
  # defer execution outside the NimbleOwnership lock. Skuld doesn't use
  # NimbleOwnership, so we invoke the deferred function immediately.
  defp unwrap_defer(%DoubleDown.Contract.Dispatch.Defer{fun: fun}), do: fun.()

  defp unwrap_defer(result), do: result

  #############################################################################
  ## Logging Helpers
  #############################################################################

  # Prepend a log entry to Port.State.log in the env. No effect dispatch,
  # just a direct env mutation — fast path for non-DB test performance.
  defp append_log(env, mod, name, args, result) do
    state_key = @state_key
    state = Env.get_state(env, state_key)
    updated = %{state | log: [{mod, name, args, result} | state.log]}
    Env.put_state(env, state_key, updated)
  end

  # For plain results: emit log (if log enabled) then continue with k.
  defp emit_log_and_continue(result, _mod, _name, _args, nil, env, k) do
    k.(result, env)
  end

  defp emit_log_and_continue(result, mod, name, args, _log, env, k) do
    k.(result, append_log(env, mod, name, args, result))
  end

  # For error results (ThrowResult): emit log (if log enabled) then return the error.
  defp emit_log_and_return(throw_result, _mod, _name, _args, nil, env) do
    {throw_result, env}
  end

  defp emit_log_and_return(throw_result, mod, name, args, _log, env) do
    {throw_result, append_log(env, mod, name, args, throw_result)}
  end

  defp invoke(:direct, mod, name, args) do
    {:value, apply(mod, name, args)}
  end

  defp invoke({:effectful, module}, _mod, name, args) when is_atom(module) do
    {:computation, apply(module, name, args)}
  end

  defp invoke(fun, mod, name, args) when is_function(fun, 3) do
    {:value, fun.(mod, name, args)}
  end

  defp invoke({module, function}, mod, name, args) do
    {:value, apply(module, function, [mod, name, args])}
  end

  defp invoke(module, _mod, name, args) when is_atom(module) do
    {:value, apply(module, name, args)}
  end
end