lib/growth_book/feature_repository.ex

defmodule GrowthBook.FeatureRepository do
  @moduledoc """
  Repository for fetching and caching features from a GrowthBook API endpoint.
  """

  use GenServer
  require Logger

  @default_ttl 60
  @default_api_host "https://cdn.growthbook.io"

  defstruct [
    :client_key,
    :api_host,
    :decryption_key,
    :swr_ttl_seconds,
    :refresh_strategy,
    :features,
    :last_fetch,
    :on_refresh_callback,
    initialization_status: :pending,
    initialization_waiters: []
  ]

  @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, pid()}
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def await_initialization(pid, timeout) do
    try do
      GenServer.call(pid, :await_initialization, timeout)
    catch
      :exit, {:timeout, _} ->
        # Properly handle timeout
        {:error, :timeout}
    end
  end

  @impl true
  def init(opts) do
    Logger.info("Initializing GrowthBook FeatureRepository")

    state = %__MODULE__{
      client_key: opts[:client_key],
      api_host: normalize_api_host(opts[:api_host]),
      decryption_key: opts[:decryption_key],
      swr_ttl_seconds: opts[:swr_ttl_seconds] || @default_ttl,
      refresh_strategy: opts[:refresh_strategy] || :periodic,
      features: %{},
      last_fetch: nil,
      on_refresh_callback: opts[:on_refresh]
    }

    Logger.info(
      "FeatureRepository configured with:" <>
        " api_host=#{state.api_host}" <>
        " ttl=#{state.swr_ttl_seconds}s" <>
        " refresh_strategy=#{state.refresh_strategy}" <>
        " decryption_enabled=#{!is_nil(state.decryption_key)}"
    )

    if state.refresh_strategy == :periodic do
      schedule_refresh(state.swr_ttl_seconds)
    end

    # Start initial fetch
    send(self(), :initialize)

    {:ok, state}
  end

  @impl true
  def handle_call(:await_initialization, from, %{initialization_status: status} = state) do
    case status do
      :ready ->
        {:reply, :ok, state}

      {:error, reason} ->
        {:reply, {:error, reason}, state}

      :pending ->
        # Monitor the caller and add to waiters list
        {pid, _} = from
        ref = Process.monitor(pid)

        {:noreply,
         %{state | initialization_waiters: [{from, ref} | state.initialization_waiters]}}
    end
  end

  @impl true
  def handle_call(:get_features, _from, state) do
    state = maybe_refresh_features(state)
    {:reply, state.features, state}
  end

  @impl true
  def handle_cast(:refresh, state) do
    {:noreply, refresh_features(state)}
  end

  @impl true
  def handle_info(:refresh, state) do
    if state.refresh_strategy == :periodic do
      schedule_refresh(state.swr_ttl_seconds)
    end

    {:noreply, refresh_features(state)}
  end

  @impl true
  def handle_info(:initialize, state) do
    case fetch_features(state) do
      {:ok, features} ->
        new_state = %{
          state
          | features: features,
            last_fetch: DateTime.utc_now(),
            initialization_status: :ready
        }

        # Notify all waiters
        Enum.each(state.initialization_waiters, fn {pid, ref} ->
          GenServer.reply(pid, :ok)
          Process.demonitor(ref)
        end)

        # Schedule periodic refresh if needed
        if state.refresh_strategy == :periodic do
          schedule_refresh(state.swr_ttl_seconds)
        end

        {:noreply, %{new_state | initialization_waiters: []}}

      {:error, reason} ->
        Logger.error("Failed to initialize features: #{inspect(reason)}")
        new_state = %{state | initialization_status: {:error, reason}}

        # Notify all waiters of the error
        Enum.each(state.initialization_waiters, fn {pid, ref} ->
          GenServer.reply(pid, {:error, reason})
          Process.demonitor(ref)
        end)

        {:noreply, %{new_state | initialization_waiters: []}}
    end
  end

  def get_features do
    GenServer.call(__MODULE__, :get_features)
  end

  def refresh do
    GenServer.cast(__MODULE__, :refresh)
  end

  def get_latest_features do
    raw_features = get_features()
    GrowthBook.Config.features_from_config(%{"features" => raw_features})
  end

  defp maybe_refresh_features(%{last_fetch: nil} = state), do: refresh_features(state)

  defp maybe_refresh_features(state) do
    elapsed = DateTime.diff(DateTime.utc_now(), state.last_fetch)

    if elapsed > state.swr_ttl_seconds do
      Logger.debug(
        "Features cache expired (#{elapsed}s > #{state.swr_ttl_seconds}s), refreshing..."
      )

      refresh_features(state)
    else
      state
    end
  end

  defp refresh_features(state) do
    Logger.debug("Fetching features from GrowthBook API...")

    case fetch_features(state) do
      {:ok, features} ->
        Logger.info("Successfully fetched #{map_size(features)} features from GrowthBook")
        new_state = %{state | features: features, last_fetch: DateTime.utc_now()}

        if is_function(state.on_refresh_callback) do
          try do
            state.on_refresh_callback.(features)
          rescue
            e ->
              Logger.error("Error in refresh callback: #{Exception.message(e)}")
          end
        end

        new_state

      {:error, reason} ->
        Logger.error("Failed to fetch features: #{inspect(reason)}")
        # Keep existing features on error
        state
    end
  end

  defp fetch_features(%{api_host: host, client_key: key} = state) do
    url = "#{host}/api/features/#{key}"

    Logger.debug("Requesting features from #{url}")

    http_client = Application.get_env(:growthbook, :http_client, HTTPoison)

    try do
      case http_client.get(url) do
        {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
          parsed = Jason.decode!(body)

          features =
            cond do
              Map.has_key?(parsed, "encryptedFeatures") && state.decryption_key ->
                Logger.debug("Decrypting encrypted features")
                decrypt_features(parsed["encryptedFeatures"], state.decryption_key)

              Map.has_key?(parsed, "encryptedFeatures") ->
                Logger.error("Received encrypted features but no decryption key provided")
                {:error, "Decryption key required for encrypted features"}

              Map.has_key?(parsed, "features") ->
                Logger.debug("Using unencrypted features")
                {:ok, parsed["features"]}

              true ->
                {:error, "Invalid response format"}
            end

          case features do
            {:ok, features_map} -> {:ok, features_map}
            {:error, reason} -> {:error, reason}
          end

        {:ok, %HTTPoison.Response{status_code: status}} ->
          {:error, "API request failed with status #{status}"}

        {:error, %HTTPoison.Error{reason: reason}} ->
          {:error, "HTTP request failed: #{inspect(reason)}"}
      end
    rescue
      e in Jason.DecodeError ->
        {:error, "Failed to decode API response: #{Exception.message(e)}"}

      e ->
        {:error, "Unexpected error: #{Exception.message(e)}"}
    end
  end

  defp decrypt_features(encrypted_features, decryption_key) do
    try do
      # Ensure we're working with binaries
      encrypted_features_binary = to_string(encrypted_features)
      decryption_key_binary = to_string(decryption_key)

      case GrowthBook.DecryptionUtils.decrypt(encrypted_features_binary, decryption_key_binary) do
        {:ok, decrypted_json} ->
          case Jason.decode(decrypted_json) do
            {:ok, features} ->
              {:ok, features}

            {:error, reason} ->
              Logger.error("Failed to parse decrypted features: #{inspect(reason)}")
              {:error, "Failed to parse decrypted features"}
          end

        {:error, reason} ->
          # Already logged in DecryptionUtils
          {:error, reason}
      end
    rescue
      e ->
        Logger.error("Unexpected error in decrypt_features: #{Exception.message(e)}")
        {:error, "Decryption failed: #{Exception.message(e)}"}
    end
  end

  defp normalize_api_host(nil), do: @default_api_host
  defp normalize_api_host(""), do: @default_api_host

  defp normalize_api_host(host) do
    String.trim_trailing(host, "/")
  end

  defp schedule_refresh(ttl) do
    Process.send_after(self(), :refresh, :timer.seconds(ttl))
  end
end