Skip to main content

lib/billdog_eng/flags.ex

defmodule BilldogEng.Flags do
  @moduledoc """
  Feature-flag client supporting both remote and local (server-side) evaluation.

  Local evaluation (`local_evaluation: true`) fetches flag DEFINITIONS once,
  caches them in a `GenServer` with a 5-minute TTL, and evaluates each flag
  deterministically on this process — the correct home for flag evaluation in a
  server SDK and the cross-platform-identical algorithm shared with
  web / iOS / Android.

  Remote evaluation falls back to `POST /experiment-config`, which returns a
  pre-evaluated `feature_flags` map for the given user.

  Mirrors the Node SDK `flags.ts`.
  """

  use GenServer
  alias BilldogEng.{Murmur, Transport}

  # TTL for cached flag definitions: 5 minutes (spec §D).
  @ttl_ms 5 * 60 * 1000

  defstruct [
    :api_key,
    :transport,
    :local_evaluation,
    :enable_logging,
    definitions: %{},
    fetched_at: 0,
    definitions_set: false
  ]

  # ── Lifecycle ───────────────────────────────────────────────────────────────

  @doc false
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts)
  end

  @impl true
  def init(opts) do
    state = %__MODULE__{
      api_key: Keyword.fetch!(opts, :api_key),
      transport: Keyword.fetch!(opts, :transport),
      local_evaluation: Keyword.get(opts, :local_evaluation, false),
      enable_logging: Keyword.get(opts, :enable_logging, false)
    }

    {:ok, state}
  end

  # ── Public API ──────────────────────────────────────────────────────────────

  @doc """
  Get a flag's value for a user. Returns `true`/`false` for a simple flag, the
  variant key string for a multivariate flag, or `nil` when the flag is unknown.
  """
  @spec get_feature_flag(pid(), String.t(), String.t(), keyword()) ::
          boolean() | String.t() | nil
  def get_feature_flag(pid, key, distinct_id, opts \\ []) do
    GenServer.call(pid, {:get_feature_flag, key, distinct_id, opts}, :infinity)
  end

  @doc "Boolean view of `get_feature_flag/4` (a variant string counts as ON)."
  @spec is_feature_enabled(pid(), String.t(), String.t(), keyword()) :: boolean()
  def is_feature_enabled(pid, key, distinct_id, opts \\ []) do
    case get_feature_flag(pid, key, distinct_id, opts) do
      nil -> false
      false -> false
      _ -> true
    end
  end

  @doc """
  Get a flag's payload (variant config). Only meaningful under local evaluation.
  Returns the matched variant's payload, else the flag-level payload, else `nil`.
  """
  @spec get_feature_flag_payload(pid(), String.t(), String.t(), keyword()) :: term()
  def get_feature_flag_payload(pid, key, distinct_id, opts \\ []) do
    GenServer.call(pid, {:get_feature_flag_payload, key, distinct_id, opts}, :infinity)
  end

  @doc "Evaluate every known flag for a user."
  @spec get_all_flags(pid(), String.t(), keyword()) :: map()
  def get_all_flags(pid, distinct_id, opts \\ []) do
    GenServer.call(pid, {:get_all_flags, distinct_id, opts}, :infinity)
  end

  @doc "Force a reload of the cached flag definitions (local mode)."
  @spec reload_feature_flag_definitions(pid()) :: :ok
  def reload_feature_flag_definitions(pid) do
    GenServer.call(pid, :reload_definitions, :infinity)
  end

  @doc """
  Inject flag definitions directly, bypassing the network. Primarily for tests
  and for hosts that distribute definitions through their own channel.
  """
  @spec set_definitions(pid(), [map()]) :: :ok
  def set_definitions(pid, defs) do
    GenServer.call(pid, {:set_definitions, defs}, :infinity)
  end

  # ── Server callbacks ─────────────────────────────────────────────────────────

  @impl true
  def handle_call({:get_feature_flag, key, distinct_id, opts}, _from, state) do
    if state.local_evaluation do
      state = ensure_definitions(state)

      result =
        if Map.has_key?(state.definitions, key) do
          evaluate_locally(state, key, distinct_id, person_props(opts))
        else
          nil
        end

      {:reply, result, state}
    else
      map = fetch_remote(state, distinct_id, person_props(opts))
      result = if Map.has_key?(map, key), do: Map.get(map, key), else: nil
      {:reply, result, state}
    end
  end

  def handle_call({:get_feature_flag_payload, key, distinct_id, opts}, _from, state) do
    state = ensure_definitions(state)
    def = Map.get(state.definitions, key)

    result =
      cond do
        is_nil(def) ->
          nil

        true ->
          verdict = evaluate_locally(state, key, distinct_id, person_props(opts))

          cond do
            verdict == false ->
              nil

            is_binary(verdict) and is_list(def["variants"]) ->
              variant = Enum.find(def["variants"], fn v -> v["key"] == verdict end)

              if variant && Map.has_key?(variant, "payload") do
                variant["payload"]
              else
                Map.get(def, "payload")
              end

            true ->
              Map.get(def, "payload")
          end
      end

    {:reply, result, state}
  end

  def handle_call({:get_all_flags, distinct_id, opts}, _from, state) do
    if state.local_evaluation do
      state = ensure_definitions(state)

      out =
        Enum.into(state.definitions, %{}, fn {key, _def} ->
          {key, evaluate_locally(state, key, distinct_id, person_props(opts))}
        end)

      {:reply, out, state}
    else
      {:reply, fetch_remote(state, distinct_id, person_props(opts)), state}
    end
  end

  def handle_call(:reload_definitions, _from, state) do
    {:reply, :ok, fetch_definitions(state)}
  end

  def handle_call({:set_definitions, defs}, _from, state) do
    definitions =
      defs
      |> Enum.map(&normalize_def/1)
      |> Map.new(fn d -> {d["key"], d} end)

    {:reply, :ok, %{state | definitions: definitions, fetched_at: now_ms(), definitions_set: true}}
  end

  # ── Internals ────────────────────────────────────────────────────────────────

  defp person_props(opts) do
    Keyword.get(opts, :person_properties, %{})
  end

  defp ensure_definitions(state) do
    fresh? =
      (state.definitions_set or map_size(state.definitions) > 0) and
        now_ms() - state.fetched_at < @ttl_ms

    if fresh? do
      state
    else
      fetch_definitions(state)
    end
  end

  defp fetch_definitions(state) do
    case Transport.request(state.transport,
           path: "/feature-flag-definitions",
           body: %{api_key: state.api_key},
           headers: [{"x-api-key", state.api_key}],
           gzip: false
         ) do
      {:ok, data} ->
        flags = (is_map(data) && Map.get(data, "flags")) || []

        definitions =
          flags
          |> Enum.map(&normalize_def/1)
          |> Map.new(fn d -> {d["key"], d} end)

        %{state | definitions: definitions, fetched_at: now_ms()}

      {:error, _err} ->
        # Graceful: keep any existing cache.
        state
    end
  end

  defp fetch_remote(state, distinct_id, attributes) do
    case Transport.request(state.transport,
           path: "/experiment-config",
           body: %{api_key: state.api_key, user_id: distinct_id, attributes: attributes || %{}},
           headers: [{"x-api-key", state.api_key}],
           gzip: false
         ) do
      {:ok, data} when is_map(data) -> Map.get(data, "feature_flags") || %{}
      _ -> %{}
    end
  end

  @doc """
  Deterministic local flag evaluation (spec §D):

    1. Missing/inactive → false.
    2. ALL `targeting_rules` must match `attributes`, else false.
    3. bucket = murmurhash3("{key}.{distinctId}") % 100; ON iff bucket < rollout.
    4. Multivariate: walk variants by cumulative rollout within the ON bucket.

  Returns `false` (off), `true` (on, boolean) or the chosen variant key.
  Exposed for direct use against an explicit definition map.
  """
  @spec evaluate_locally(t :: %__MODULE__{}, String.t(), String.t(), map() | nil) ::
          boolean() | String.t()
  def evaluate_locally(state, key, distinct_id, attributes) do
    def = Map.get(state.definitions, key)

    cond do
      is_nil(def) or def["active"] != true ->
        false

      not targeting_matches?(def["targeting_rules"], attributes || %{}) ->
        false

      true ->
        bucket = Murmur.hash("#{key}.#{distinct_id}", 0) |> rem(100)

        if bucket >= (def["rollout_percentage"] || 0) do
          false
        else
          resolve_variants(def["variants"], bucket)
        end
    end
  end

  defp targeting_matches?(rules, _attrs) when rules in [nil, []], do: true

  defp targeting_matches?(rules, attrs) when is_list(rules) do
    Enum.all?(rules, fn rule ->
      matches_rule?(rule, Map.get(attrs, rule["attribute"]))
    end)
  end

  defp resolve_variants(variants, _bucket) when variants in [nil, []], do: true

  defp resolve_variants(variants, bucket) when is_list(variants) do
    case walk_variants(variants, bucket, 0) do
      {:variant, key} -> key
      :none -> true
    end
  end

  defp walk_variants([], _bucket, _cumulative), do: :none

  defp walk_variants([variant | rest], bucket, cumulative) do
    cumulative = cumulative + (variant["rollout_percentage"] || 0)

    if bucket < cumulative do
      {:variant, variant["key"]}
    else
      walk_variants(rest, bucket, cumulative)
    end
  end

  # Full operator set mirroring `supabase/functions/_shared/edge-serving/pure.ts`
  # `compare()` (+ `tryParseDate`). `rule` carries the operator and (for most
  # operators) an expected `value`; `actual` is the resolved attribute value.
  defp matches_rule?(rule, actual) when is_map(rule) do
    op = rule["operator"]
    expected = rule["value"]
    actual_str = to_comparable(actual)

    cond do
      op == "exists" ->
        actual_str != nil and String.length(actual_str) > 0

      op == "not_exists" ->
        actual_str == nil or String.length(actual_str) == 0

      # For all other operators: missing actual → false.
      actual_str == nil ->
        false

      true ->
        compare(op, actual, actual_str, expected)
    end
  end

  defp matches_rule?(_rule, _value), do: false

  defp compare(op, actual, actual_str, expected) do
    case op do
      op when op in ["is", "equals"] ->
        actual_str == to_string_value(expected)

      op when op in ["is_not", "not_equals"] ->
        actual_str != to_string_value(expected)

      "any_of" ->
        is_list(expected) and actual_str in Enum.map(expected, &to_string_value/1)

      "not_any_of" ->
        is_list(expected) and actual_str not in Enum.map(expected, &to_string_value/1)

      "contains" ->
        String.contains?(actual_str, to_string_value(expected))

      "not_contains" ->
        not String.contains?(actual_str, to_string_value(expected))

      op when op in ["greater_than", "gt"] ->
        date_or_number_compare(actual, actual_str, expected, &>/2)

      op when op in ["less_than", "lt"] ->
        date_or_number_compare(actual, actual_str, expected, &</2)

      op when op in ["greater_than_or_equal", "gte"] ->
        date_or_number_compare(actual, actual_str, expected, &>=/2)

      op when op in ["less_than_or_equal", "lte"] ->
        date_or_number_compare(actual, actual_str, expected, &<=/2)

      _ ->
        false
    end
  end

  # Try to parse BOTH actual and expected as ISO dates (date-shaped regex guard
  # first, mirroring `tryParseDate`); if BOTH parse, compare epoch ms. Otherwise
  # fall back to numeric comparison via Float.parse.
  defp date_or_number_compare(actual, actual_str, expected, op) do
    case {try_parse_date(actual), try_parse_date(expected)} do
      {act_ms, exp_ms} when is_integer(act_ms) and is_integer(exp_ms) ->
        op.(act_ms, exp_ms)

      _ ->
        case {to_number(actual_str), to_number(expected)} do
          {a, b} when is_number(a) and is_number(b) -> op.(a, b)
          _ -> false
        end
    end
  end

  # Mirror pure.ts `tryParseDate`: only strings matching `^\d{4}-\d{2}-\d{2}`
  # that actually parse become dates. Returns epoch milliseconds or nil.
  defp try_parse_date(%Date{} = d), do: date_to_ms(d)
  defp try_parse_date(%DateTime{} = dt), do: DateTime.to_unix(dt, :millisecond)

  defp try_parse_date(value) when is_binary(value) do
    if Regex.match?(~r/^\d{4}-\d{2}-\d{2}/, value) do
      case DateTime.from_iso8601(value) do
        {:ok, dt, _offset} ->
          DateTime.to_unix(dt, :millisecond)

        _ ->
          case Date.from_iso8601(String.slice(value, 0, 10)) do
            {:ok, d} -> date_to_ms(d)
            _ -> nil
          end
      end
    else
      nil
    end
  end

  defp try_parse_date(_), do: nil

  defp date_to_ms(%Date{} = d) do
    {:ok, dt, _} = DateTime.from_iso8601("#{Date.to_iso8601(d)}T00:00:00Z")
    DateTime.to_unix(dt, :millisecond)
  end

  # String(actual) coercion — nil/undefined → nil (mirrors `toComparable`).
  defp to_comparable(nil), do: nil
  defp to_comparable(v) when is_binary(v), do: v
  defp to_comparable(v), do: to_string_value(v)

  # String(expected) coercion mirroring JS `String()`.
  defp to_string_value(nil), do: ""
  defp to_string_value(v) when is_binary(v), do: v
  defp to_string_value(true), do: "true"
  defp to_string_value(false), do: "false"
  defp to_string_value(v) when is_integer(v), do: Integer.to_string(v)
  defp to_string_value(v) when is_float(v), do: stringify_float(v)
  defp to_string_value(v), do: to_string(v)

  # JS prints whole-valued floats without a trailing ".0".
  defp stringify_float(v) do
    if v == Float.round(v) and abs(v) < 1.0e16 do
      v |> trunc() |> Integer.to_string()
    else
      Float.to_string(v)
    end
  end

  defp to_number(n) when is_number(n), do: n

  defp to_number(s) when is_binary(s) do
    case Float.parse(s) do
      {f, _} -> f
      :error -> :nan
    end
  end

  defp to_number(_), do: :nan

  # Normalize a definition into a string-keyed map regardless of input atom/string keys.
  defp normalize_def(def) when is_map(def) do
    Map.new(def, fn {k, v} -> {to_string_key(k), normalize_nested(k, v)} end)
  end

  defp normalize_nested(k, list) when is_list(list) and k in [:variants, "variants", :targeting_rules, "targeting_rules"] do
    Enum.map(list, fn
      item when is_map(item) -> Map.new(item, fn {ik, iv} -> {to_string_key(ik), iv} end)
      item -> item
    end)
  end

  defp normalize_nested(_k, v), do: v

  defp to_string_key(k) when is_atom(k), do: Atom.to_string(k)
  defp to_string_key(k), do: k

  defp now_ms, do: System.system_time(:millisecond)
end