lib/exshome/dependency/gen_server_dependency.ex

defmodule Exshome.Dependency.GenServerDependency do
  @moduledoc """
  This module stores generic API for GenServer based dependencies.
  """
  use GenServer

  alias Exshome.Dependency
  alias Exshome.Event

  defmodule DependencySupervisor do
    @moduledoc """
    Supervisor that starts all GenServerDependencies
    """

    use Supervisor, shutdown: :infinity
    alias Exshome.Dependency.GenServerDependency

    def start_link(opts) when is_map(opts) do
      {supervisor_opts, child_opts} = Map.pop(opts, :supervisor_opts, name: __MODULE__)
      Supervisor.start_link(__MODULE__, child_opts, supervisor_opts)
    end

    @impl Supervisor
    def init(child_opts) when is_map(child_opts) do
      {apps, child_opts} = Map.pop(child_opts, :apps, Exshome.App.apps())

      apps
      |> Enum.map(&GenServerDependency.modules/1)
      |> Enum.map(&MapSet.to_list/1)
      |> List.flatten()
      |> Enum.map(&{&1, child_opts})
      |> Supervisor.init(strategy: :one_for_one)
    end
  end

  defmodule DependencyState do
    @moduledoc """
    Inner state for each dependency.
    """

    defstruct [:module, :opts, :deps, :data, value: Dependency.NotReady]

    @type t() :: %__MODULE__{
            module: module(),
            deps: map(),
            data: any(),
            opts: any(),
            value: Dependency.value()
          }
  end

  @callback parse_opts(map()) :: any()
  @callback update_value(DependencyState.t(), value :: any()) :: DependencyState.t()
  @callback handle_dependency_change(DependencyState.t()) :: DependencyState.t()
  @callback handle_event(Event.event_message(), DependencyState.t()) :: DependencyState.t()
  @callback on_init(DependencyState.t()) :: DependencyState.t()
  @callback handle_info(message :: any(), DependencyState.t()) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:stop, reason :: term(), new_state}
            when new_state: DependencyState.t()
  @callback handle_call(request :: term(), GenServer.from(), state :: DependencyState.t()) ::
              {:reply, reply, new_state}
              | {:reply, reply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:noreply, new_state}
              | {:noreply, new_state, timeout() | :hibernate | {:continue, term()}}
              | {:stop, reason, reply, new_state}
              | {:stop, reason, new_state}
            when reply: term(), new_state: DependencyState.t(), reason: term()
  @optional_callbacks handle_info: 2, handle_call: 3

  @spec start_link(map()) :: GenServer.on_start()
  def start_link(%{module: module} = opts) do
    {name, opts} = Map.pop(opts, :name, module)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  @impl GenServer
  def init(opts) do
    Process.flag(:trap_exit, true)

    {module, opts} = Map.pop!(opts, :module)

    parsed_opts = module.parse_opts(opts)

    dependencies = module.__config__()[:dependencies] || []
    events = module.__config__()[:events] || []

    state =
      %DependencyState{module: module, deps: %{}, opts: parsed_opts}
      |> subscribe_to_dependencies(dependencies)
      |> subscribe_to_events(events)

    {:ok, state, {:continue, :on_init}}
  end

  @impl GenServer
  def handle_continue(:on_init, %DependencyState{} = state) do
    new_state = state.module.on_init(state)
    {:noreply, new_state}
  end

  @impl GenServer
  def handle_call(:get_value, _from, %DependencyState{} = state) do
    {:reply, state.value, state}
  end

  @impl GenServer
  def handle_call(message, from, %DependencyState{} = state) do
    state.module.handle_call(message, from, state)
  end

  @impl GenServer
  def handle_info({Dependency, message}, %DependencyState{} = state) do
    {:noreply, handle_dependency_info(message, state)}
  end

  @impl GenServer
  def handle_info({Event, event}, %DependencyState{} = state) do
    new_state = state.module.handle_event(event, state)
    {:noreply, new_state}
  end

  @impl GenServer
  def handle_info(message, %DependencyState{} = state) do
    state.module.handle_info(message, state)
  end

  @impl GenServer
  def terminate(_reason, %DependencyState{module: module}) do
    Dependency.broadcast_value(module, Dependency.NotReady)
  end

  @spec get_pid(atom() | pid()) :: pid() | nil
  defp get_pid(server) when is_atom(server), do: Process.whereis(server)

  defp get_pid(server) when is_pid(server) do
    if Process.alive?(server), do: server, else: nil
  end

  @spec get_value(GenServer.server()) :: any()
  def get_value(server) do
    call(server, :get_value)
  end

  @spec call(GenServer.server(), any()) :: any()
  def call(server, message) do
    case get_pid(server) do
      nil -> Dependency.NotReady
      pid -> GenServer.call(pid, message)
    end
  end

  @spec update_value(DependencyState.t(), value :: any()) :: DependencyState.t()
  def update_value(%DependencyState{} = state, value) do
    old_value = state.value

    if value != old_value do
      Dependency.broadcast_value(state.module, value)
    end

    %DependencyState{state | value: value}
  end

  @spec update_data(DependencyState.t(), (any() -> any())) :: DependencyState.t()
  def update_data(%DependencyState{} = state, update_fn) do
    %DependencyState{state | data: update_fn.(state.data)}
  end

  @spec handle_dependency_change(DependencyState.t()) :: DependencyState.t()
  def handle_dependency_change(%DependencyState{deps: deps} = state) do
    missing_dependencies =
      deps
      |> Map.values()
      |> Enum.any?(&(&1 == Dependency.NotReady))

    if missing_dependencies do
      update_value(state, Dependency.NotReady)
    else
      state.module.handle_dependency_change(state)
    end
  end

  @spec handle_dependency_info(any(), DependencyState.t()) :: DependencyState.t()
  def handle_dependency_info({dependency, value}, %DependencyState{} = state) do
    key =
      state.module.__config__()[:dependencies]
      |> Keyword.fetch!(dependency)

    put_in(state.deps[key], value)
    |> handle_dependency_change()
  end

  @spec subscribe_to_dependencies(DependencyState.t(), Enumerable.t()) :: DependencyState.t()
  def subscribe_to_dependencies(%DependencyState{} = state, dependencies) do
    deps =
      for {dependency, key} <- dependencies, into: %{} do
        {key, Dependency.subscribe(dependency)}
      end

    state = %DependencyState{state | deps: deps}

    if Enum.empty?(deps) do
      state
    else
      handle_dependency_change(state)
    end
  end

  @spec subscribe_to_events(DependencyState.t(), Enumerable.t()) :: DependencyState.t()
  def subscribe_to_events(%DependencyState{} = state, events) do
    for event_module <- events do
      :ok = Event.subscribe(event_module)
    end

    state
  end

  @hook_module Application.compile_env(:exshome, :dependency_hook_module)
  if @hook_module do
    defoverridable(get_pid: 1)
    defdelegate get_pid(server), to: @hook_module
    defoverridable(init: 1)

    def init(opts) do
      @hook_module.init(opts)
      result = super(opts)
      result
    end
  end

  @spec validate_module!(Macro.Env.t(), String.t()) :: keyword()
  def validate_module!(%Macro.Env{} = env, _bytecode) do
    validate_config!(env.module.__config__())
  end

  @doc """
  Validates configuration for the dependency and raises if it is invalid.
  Available configuration options:
  :name (required) - name of the dependency
  :dependencies (default []) - dependencies list
  :events (default []) - events to subscribe, where key is a module, and value is a topic
  """
  @spec validate_config!(Keyword.t()) :: keyword()
  def validate_config!(config) do
    NimbleOptions.validate!(
      config,
      name: [
        type: :string,
        required: true
      ],
      dependencies: [
        type: :keyword_list,
        keys: [
          *: [
            type: :atom
          ]
        ]
      ],
      events: [
        type: {:list, :atom}
      ]
    )
  end

  @spec modules(app :: atom()) :: MapSet.t(Dependency.dependency())
  def modules(app) when is_atom(app) do
    Map.get(
      Exshome.Tag.tag_mapping(),
      {__MODULE__, app},
      MapSet.new()
    )
  end

  defmacro __using__(config) do
    quote do
      require Logger
      alias unquote(__MODULE__)
      alias unquote(__MODULE__).DependencyState
      use Exshome.Dependency
      use Exshome.Named, "dependency:#{unquote(config[:name])}"

      app_module =
        __MODULE__
        |> Module.split()
        |> Enum.slice(0..0)
        |> Module.safe_concat()

      add_tag({GenServerDependency, app_module})

      @after_compile {GenServerDependency, :validate_module!}
      @behaviour GenServerDependency

      def __config__, do: unquote(config)

      @impl GenServerDependency
      defdelegate update_value(state, value), to: GenServerDependency
      defdelegate update_data(state, data_fn), to: GenServerDependency

      @impl Exshome.Dependency
      def get_value, do: GenServerDependency.get_value(__MODULE__)

      @impl GenServerDependency
      def parse_opts(opts), do: opts
      @impl GenServerDependency
      def on_init(state), do: state

      @impl GenServerDependency
      def handle_dependency_change(state) do
        Logger.warn("""
        Some module dependency changed.
        Please implement handle_dependency_change/1 callback for #{state.module}
        """)

        state
      end

      @impl GenServerDependency
      def handle_event(event, %DependencyState{} = state) do
        Logger.warn("""
        Received unexpected event #{inspect(event)},
        Please implement handle_event/2 callback for #{state.module}
        """)

        state
      end

      defoverridable(parse_opts: 1, on_init: 1, handle_dependency_change: 1, handle_event: 2)

      def call(message), do: GenServerDependency.call(__MODULE__, message)

      def start_link(opts), do: opts |> update_opts() |> GenServerDependency.start_link()

      def child_spec(opts) do
        opts
        |> update_opts()
        |> GenServerDependency.child_spec()
        |> Map.merge(%{id: __MODULE__})
      end

      defp update_opts(%{} = opts) do
        Map.merge(opts, %{module: __MODULE__})
      end
    end
  end
end