Skip to main content

lib/kameleoon/native/events.ex

defmodule Kameleoon.Native.Events do
  @moduledoc false

  use GenServer

  require Logger

  alias Kameleoon.Client
  alias Kameleoon.Error

  @type client_key :: {String.t(), String.t() | nil}
  @type state :: %{
          handlers: %{client_key() => (() -> any())},
          logger: Kameleoon.Logger.logger() | nil
        }

  @spec start(keyword()) :: GenServer.on_start()
  def start(opts \\ []) do
    name = Keyword.get(opts, :name, __MODULE__)
    opts = Keyword.put(opts, :name, name)
    GenServer.start(__MODULE__, opts, name: name)
  end

  @spec ensure_started(GenServer.server()) :: {:ok, pid()} | {:error, Error.t()}
  def ensure_started(server \\ __MODULE__)

  def ensure_started(server) when is_pid(server), do: {:ok, server}

  def ensure_started(server) when is_atom(server) do
    case Process.whereis(server) do
      nil ->
        case start(name: server) do
          {:ok, pid} ->
            {:ok, pid}

          {:error, {:already_started, pid}} ->
            {:ok, pid}

          {:error, reason} ->
            {:error, %Error{message: "failed to start native events: #{inspect(reason)}"}}
        end

      pid ->
        {:ok, pid}
    end
  end

  def ensure_started(server) do
    case GenServer.whereis(server) do
      nil -> {:error, %Error{message: "native events process is not started: #{inspect(server)}"}}
      pid -> {:ok, pid}
    end
  end

  @spec set_logger(Kameleoon.Logger.logger() | nil, GenServer.server()) ::
          {:ok, pid()} | {:error, Error.t()}
  def set_logger(logger, server \\ __MODULE__) do
    with {:ok, pid} <- ensure_started(server),
         :ok <- GenServer.call(server, {:set_logger, logger}) do
      {:ok, pid}
    end
  end

  @spec forget(String.t(), String.t() | nil, GenServer.server()) :: :ok | {:error, Error.t()}
  def forget(site_code, environment, server \\ __MODULE__) do
    with {:ok, _pid} <- ensure_started(server) do
      GenServer.call(server, {:forget, site_code, environment})
    end
  end

  @spec set_handler(Client.t(), (() -> any()) | nil, GenServer.server()) ::
          :ok | {:error, Error.t()}
  def set_handler(client, fun, server \\ __MODULE__) when is_function(fun, 0) or is_nil(fun) do
    with {:ok, _pid} <- ensure_started(server) do
      GenServer.call(server, {:set_handler, client_key(client), fun})
    end
  end

  @impl true
  def init(_opts) do
    {:ok, %{handlers: %{}, logger: nil}}
  end

  @impl true
  def handle_call({:set_logger, logger}, _from, state) do
    {:reply, :ok, %{state | logger: logger}}
  end

  def handle_call({:forget, site_code, nil}, _from, state) do
    keys =
      state.handlers
      |> Map.keys()
      |> Enum.filter(fn {tracked_site, _env} -> tracked_site == site_code end)

    {:reply, :ok, forget_keys(state, keys)}
  end

  def handle_call({:forget, site_code, environment}, _from, state) do
    {:reply, :ok, forget_keys(state, [{site_code, environment}])}
  end

  def handle_call({:set_handler, key, nil}, _from, state) do
    handlers = Map.delete(state.handlers, key)
    {:reply, :ok, %{state | handlers: handlers}}
  end

  def handle_call({:set_handler, key, fun}, _from, state) do
    {:reply, :ok, %{state | handlers: Map.put(state.handlers, key, fun)}}
  end

  @impl true
  def handle_info({:datafile_updated, site_code, environment}, state) do
    {:noreply, dispatch_datafile_updated(state, {site_code, environment})}
  end

  def handle_info({:kameleoon_log, level, message}, state) do
    dispatch_log(state.logger, level, message)
    {:noreply, state}
  end

  def handle_info(message, state) do
    Logger.warning("ignoring unknown native event message: #{inspect(message)}")
    {:noreply, state}
  end

  defp dispatch_datafile_updated(state, key) do
    if handler = Map.get(state.handlers, key) do
      Task.start(fn ->
        try do
          handler.()
        rescue
          error ->
            Logger.warning("configuration update handler crashed: #{Exception.message(error)}")
        end
      end)
    end

    state
  end

  defp dispatch_log(nil, _level, _message), do: :ok
  defp dispatch_log(module, level, message), do: safe_log(fn -> module.log(level, message) end)

  defp safe_log(fun) do
    fun.()
  rescue
    error ->
      Logger.warning("kameleoon logger callback crashed: #{Exception.message(error)}")
  catch
    kind, reason ->
      Logger.warning("kameleoon logger callback exited: #{inspect({kind, reason})}")
  end

  defp forget_keys(state, keys) do
    Enum.reduce(keys, state, fn key, state ->
      %{
        state
        | handlers: Map.delete(state.handlers, key)
      }
    end)
  end

  defp client_key(%Client{site_code: site_code, environment: environment}),
    do: {site_code, environment}
end