Skip to main content

lib/skuld/page_machine/contract.ex

defmodule Skuld.PageMachine.Contract do
  @moduledoc """
  Typed protocol contract for PageMachine spindle ↔ LiveView communication.

  `use Skuld.PageMachine.Contract` imports `defspindle`, `defevent`,
  and `defyield` macros to declare the typed interface between a page's
  spindles and its LiveView.

  Each declaration is validated at compile time. The protocol module acts
  as the single source of truth for event routing and yield shapes.

  ## Usage

      defmodule MyApp.StoreProtocol do
        use Skuld.PageMachine.Contract

        defspindle Products do
          defevent "search", SearchEvent, params: [query: String.t()]
          defevent "filter", FilterEvent, params: [filters: map()]
          defevent "buy", BuyEvent, params: [product: Product.t()]

          defyield :browsing
          defyield results(products: [Product.t()], total: integer())
        end

        defspindle Checkout do
          defevent "submit_shipping", ShippingEvent, params: [shipping: map()]
          defevent "submit_payment", PaymentEvent, params: [payment: map()]

          defyield :shipping
          defyield :payment
        end
      end

  Events with an explicit struct name generate a typed struct module
  under the spindle module (`StoreProtocol.Products.SearchEvent`).
  The auto-generated `handle_event` wraps params into the struct before
  resuming the spindle, giving typed receive-side pattern matching.

  Events without a struct name pass `{event_name, params}` as the
  resume value (LiveView convention).

  The spindle key is the module atom (`StoreProtocol.Products`) — the
  same module where yield functions are generated:

      StoreProtocol.Products.results(products: prods, total: n)
      StoreProtocol.Checkout.shipping()

  ## Integration with PageMachine

      use Skuld.PageMachine,
        protocol: MyApp.StoreProtocol,
        on_yield: &handle_yield/3

  The `:protocol` option auto-generates `handle_event/3` clauses from
  the protocol's event declarations.
  """

  @doc false
  defmacro __using__(_opts) do
    quote do
      import Skuld.PageMachine.Contract,
        only: [
          defspindle: 2,
          defevent: 1,
          defevent: 2,
          defevent: 3,
          defyield: 1,
          defnotify: 1
        ]

      Module.register_attribute(__MODULE__, :pm_events, accumulate: true)
      Module.register_attribute(__MODULE__, :pm_yields, accumulate: true)
      Module.register_attribute(__MODULE__, :pm_spindles, accumulate: true)
      @before_compile Skuld.PageMachine.Contract
    end
  end

  @doc """
  Open a spindle block.

  Inside the block, `defevent` and `defyield` infer the spindle from the
  enclosing `defspindle` context — no need to repeat the spindle key.

  The spindle name becomes the module atom under the protocol module
  (e.g., `defspindle Products` → `StoreProtocol.Products`), which is
  both the spindle key for event routing and the module where typed
  yield functions are generated.

  ## Example

      defspindle Products do
        defevent "search", SearchEvent, params: [query: String.t()]
        defyield :browsing
        defyield results(products: [Product.t()], total: integer())
      end
  """
  defmacro defspindle(name, do: block) do
    spindle_name =
      case name do
        {atom_name, _meta, nil} when is_atom(atom_name) -> atom_name
        {:__aliases__, _meta, segments} -> List.last(segments)
      end

    spindle_atom = Module.concat(__CALLER__.module, spindle_name)

    Module.put_attribute(__CALLER__.module, :current_spindle, spindle_atom)

    quote do
      @pm_spindles unquote(spindle_atom)
      unquote(block)
      Module.delete_attribute(__MODULE__, :current_spindle)
    end
  end

  @doc """
  Declare a LiveView event routed to the current spindle.

  Without params, the spindle receives `{event_name, params}` (standard
  LiveView convention).

  With params and a struct name, a typed event struct is generated and
  the spindle receives it directly — enabling typed receive-side
  pattern matching.

  ## Syntax

      defevent "event_name"
      defevent "event_name", StructName, params: [field: type(), ...]
  """
  defmacro defevent(event_name) when is_binary(event_name) do
    build_defevent(event_name, nil, [], __CALLER__)
  end

  defmacro defevent(event_name, opts) when is_list(opts) do
    params = Keyword.get(opts, :params)
    build_defevent(event_name, nil, params || [], __CALLER__)
  end

  defmacro defevent(event_name, struct_name, opts) when is_list(opts) do
    params = Keyword.get(opts, :params)
    struct_atom = resolve_struct_name(struct_name)

    build_defevent(event_name, struct_atom, params || [], __CALLER__)
  end

  defp resolve_struct_name({:__aliases__, _meta, segments}), do: List.last(segments)
  defp resolve_struct_name({atom_name, _meta, nil}) when is_atom(atom_name), do: atom_name
  defp resolve_struct_name(atom) when is_atom(atom), do: atom

  defp build_defevent(event_name, struct_name, params, caller) do
    spindle = Module.get_attribute(caller.module, :current_spindle)

    unless spindle do
      raise CompileError,
        description: "defevent outside a defspindle block",
        file: caller.file
    end

    event = %{
      event: clean_event_name(event_name),
      spindle: spindle,
      struct_name: struct_name,
      params: params
    }

    escaped = Macro.escape(event)

    quote do
      @pm_events unquote(escaped)
    end
  end

  @doc """
  Declare a yield from the current spindle to the LiveView.

  ## Syntax

      defyield :tag
      defyield tag(key: type(), ...)
  """
  defmacro defyield(tag) when is_atom(tag) do
    build_defyield(tag, [], __CALLER__)
  end

  defmacro defyield({tag, _meta, args}) when is_atom(tag) do
    params = extract_keyword_params(args)
    build_defyield(tag, params, __CALLER__)
  end

  defp extract_keyword_params([keyword_list]) when is_list(keyword_list) do
    Enum.map(keyword_list, fn
      {key, val} when is_atom(key) -> {key, val}
    end)
  end

  defp build_defyield(tag, params, caller) do
    yield = %{
      spindle: get_spindle!(caller),
      tag: tag,
      params: params,
      nest: :yield
    }

    escaped = Macro.escape(yield)

    quote do
      @pm_yields unquote(escaped)
    end
  end

  @doc """
  Declare a fire-and-forget notification from the current spindle.

  Same syntax as `defyield`, but generates a function that calls
  `FiberYield.notify/1` instead of `Yield.yield/1` — the spindle
  surfaces the value to the caller without pausing.

  ## Syntax

      defnotify :tag
      defnotify tag(key: type(), ...)
  """
  defmacro defnotify(tag) when is_atom(tag) do
    build_defnotify(tag, [], __CALLER__)
  end

  defmacro defnotify({tag, _meta, args}) when is_atom(tag) do
    params = extract_keyword_params(args)
    build_defnotify(tag, params, __CALLER__)
  end

  defp build_defnotify(tag, params, caller) do
    yield = %{
      spindle: get_spindle!(caller),
      tag: tag,
      params: params,
      nest: :notify
    }

    escaped = Macro.escape(yield)

    quote do
      @pm_yields unquote(escaped)
    end
  end

  defp get_spindle!(caller) do
    case Module.get_attribute(caller.module, :current_spindle) do
      nil ->
        raise CompileError,
          description: "defyield outside a defspindle block",
          file: caller.file

      spindle ->
        spindle
    end
  end

  @doc false
  defmacro __before_compile__(env) do
    events = Module.get_attribute(env.module, :pm_events) |> Enum.reverse()
    yields = Module.get_attribute(env.module, :pm_yields) |> Enum.reverse()

    validate_events!(events, env)
    validate_yields!(yields, env)

    spindle_modules = generate_spindle_modules(env.module, yields)
    event_struct_modules = generate_event_struct_modules(env.module, events)
    introspection = generate_introspection(events, yields)
    event_list = generate_event_list(env.module, events)

    quote do
      unquote_splicing(spindle_modules)
      unquote_splicing(event_struct_modules)
      unquote(introspection)
      unquote(event_list)
    end
  end

  defp validate_events!(events, env) do
    if events == [] do
      raise CompileError,
        description:
          "#{inspect(env.module)} has no defevent declarations. " <>
            "Add at least one defevent to define the LiveView → spindle event contract.",
        file: env.file
    end

    Enum.each(events, fn %{event: event} ->
      unless is_binary(event) and byte_size(event) > 0 do
        raise CompileError,
          description: "defevent event name must be a non-empty string",
          file: env.file
      end
    end)

    event_names = Enum.map(events, & &1.event)
    unique = MapSet.new(event_names) |> MapSet.size()

    if unique != length(event_names) do
      dups = event_names -- Enum.uniq(event_names)

      raise CompileError,
        description: "Duplicate event names: #{inspect(dups)}",
        file: env.file
    end
  end

  defp validate_yields!(yields, env) do
    if yields == [] do
      raise CompileError,
        description:
          "#{inspect(env.module)} has no defyield declarations. " <>
            "Add at least one defyield to define the spindle → LiveView yield contract.",
        file: env.file
    end

    tag_pairs = Enum.map(yields, fn %{spindle: s, tag: t} -> {s, t} end)
    unique = MapSet.new(tag_pairs) |> MapSet.size()

    if unique != length(yields) do
      dups = tag_pairs -- Enum.uniq(tag_pairs)

      raise CompileError,
        description: "Duplicate spindle/tag pairs in defyield: #{inspect(dups)}",
        file: env.file
    end
  end

  defp generate_spindle_modules(_protocol_module, yields) do
    yields
    |> Enum.group_by(&{&1.spindle, &1.nest})
    |> Enum.map(fn {{spindle_module, nest}, spindle_yields} ->
      nest_module = Module.concat(spindle_module, to_pascal_case(nest))

      struct_defs = Enum.map(spindle_yields, &generate_yield_struct(nest_module, &1))

      yield_fns = Enum.map(spindle_yields, &generate_yield_fn(nest_module, &1))

      quote do
        defmodule unquote(nest_module) do
          @moduledoc false
          unquote_splicing(struct_defs)
          unquote_splicing(yield_fns)
        end
      end
    end)
  end

  defp generate_yield_struct(spindle_module, %{tag: tag, params: params}) do
    struct_name = to_pascal_case(tag)
    struct_module = Module.concat(spindle_module, struct_name)

    fields = Enum.map(params, fn {name, _type} -> name end)
    field_defaults = Enum.map(fields, &{&1, nil})
    type_spec = Enum.map(params, fn {name, type} -> {name, type} end)

    quote do
      defmodule unquote(struct_module) do
        @moduledoc false

        @type t :: %__MODULE__{
                unquote_splicing(type_spec)
              }

        defstruct unquote(field_defaults)
      end
    end
  end

  defp generate_yield_fn(spindle_module, %{tag: tag, params: params, nest: nest}) do
    struct_name = to_pascal_case(tag)
    struct_module = Module.concat(spindle_module, struct_name)

    {effect_mod, effect_fun} =
      case nest do
        :notify -> {Skuld.Effects.FiberYield, :notify}
        :yield -> {Skuld.Effects.Yield, :yield}
      end

    if params == [] do
      quote do
        @spec unquote(tag)() :: unquote(struct_module).t()
        def unquote(tag)() do
          unquote(effect_mod).unquote(effect_fun)(%unquote(struct_module){})
        end
      end
    else
      field_names = Enum.map(params, fn {name, _type} -> name end)

      fetch_bindings =
        Enum.map(field_names, fn name ->
          var = Macro.var(name, nil)

          quote do
            unquote(var) = Keyword.fetch!(opts, unquote(name))
          end
        end)

      field_kvs =
        Enum.map(field_names, fn name ->
          {name, Macro.var(name, nil)}
        end)

      quote do
        @spec unquote(tag)(keyword()) :: Skuld.Comp.Types.computation()
        def unquote(tag)(opts) do
          unquote_splicing(fetch_bindings)
          struct = struct(unquote(struct_module), unquote(field_kvs))
          unquote(effect_mod).unquote(effect_fun)(struct)
        end
      end
    end
  end

  defp generate_event_struct_modules(_protocol_module, events) do
    events
    |> Enum.filter(fn %{struct_name: sn} -> sn != nil end)
    |> Enum.map(fn %{spindle: spindle, struct_name: struct_name, params: params} ->
      struct_module = Module.concat(spindle, struct_name)

      fields = Enum.map(params, fn {name, _type} -> name end)
      field_defaults = Enum.map(fields, &{&1, nil})
      type_spec = Enum.map(params, fn {name, type} -> {name, type} end)

      quote do
        defmodule unquote(struct_module) do
          @moduledoc false

          @type t :: %__MODULE__{
                  unquote_splicing(type_spec)
                }

          defstruct unquote(field_defaults)
        end
      end
    end)
  end

  defp generate_introspection(events, yields) do
    escaped_events = Macro.escape(events)
    escaped_yields = Macro.escape(yields)

    quote do
      @doc """
      Returns the list of defevent declarations as maps.
      """
      @spec __contract_events__() :: [map()]
      def __contract_events__, do: unquote(escaped_events)

      @doc """
      Returns the list of defyield declarations as maps.
      """
      @spec __contract_yields__() :: [map()]
      def __contract_yields__, do: unquote(escaped_yields)
    end
  end

  defp generate_event_list(_protocol_module, events) do
    entries =
      Enum.map(events, fn %{
                            event: event,
                            spindle: spindle,
                            struct_name: struct_name,
                            params: params
                          } ->
        {event, spindle, struct_name, params}
      end)

    escaped = Macro.escape(entries)

    quote do
      @doc false
      def __pm_events__, do: unquote(escaped)
    end
  end

  @doc false
  def to_pascal_case(atom) when is_atom(atom) do
    atom
    |> Atom.to_string()
    |> String.split("_")
    |> Enum.map_join(fn s -> String.capitalize(s) end)
    |> String.to_atom()
  end

  defp clean_event_name(event) when is_binary(event), do: event
end