lib/flagsmith_engine.ex

defmodule Flagsmith.Engine do
  require Logger

  alias Flagsmith.Schemas.{
    Environment,
    Traits,
    Segments,
    Identity,
    Features
  }

  alias Traits.Trait
  alias Flagsmith.Schemas.Types
  @condition_operators Flagsmith.Schemas.Types.Operator.values(:atoms)

  @moduledoc false

  @doc """
  Generate a valid environment struct from a json string or map.
  """
  @spec parse_environment(data :: map() | String.t()) ::
          {:ok, Environment.t()} | {:error, Ecto.Changeset.t()} | {:error, term()}
  def parse_environment(data) when is_map(data) and not is_struct(data),
    do: Environment.cast(data)

  def parse_environment(data) when is_binary(data) do
    case Jason.decode(data) do
      {:ok, decoded} -> parse_environment(decoded)
      {:error, _} = error -> error
    end
  end

  @doc """
  Get the feature states of an `t:Flagsmith.Schemas.Environment.t/0`.
  """
  @spec get_environment_feature_states(Environment.t()) :: list(Environment.FeatureState.t())
  def get_environment_feature_states(%Environment{
        feature_states: feature_states,
        project: %Environment.Project{hide_disabled_flags: hide_disabled_flags}
      }) do
    case hide_disabled_flags do
      false -> feature_states
      true -> Enum.filter(feature_states, & &1.enabled)
    end
  end

  @doc """
  Get a specific feature state for a given feature_name in a given 
  `t:Flagsmith.Schemas.Environment.t/0`.
  """
  @spec get_environment_feature_state(Environment.t(), name :: String.t()) ::
          Environment.FeatureState.t() | nil
  def get_environment_feature_state(%Environment{feature_states: fs}, name),
    do: Enum.find(fs, fn %{feature: %{name: f_name}} -> f_name == name end)

  @doc """
  Get list of feature states for a given `t:Flagsmith.Schemas.Identity.t/0` in a 
  given `t:Flagsmith.Schemas.Environment.t/0`.
  """
  @spec get_identity_feature_states(
          Environment.t(),
          Identity.t(),
          override_traits :: list(Traits.Trait.t())
        ) :: list(Environment.FeatureState.t())
  def get_identity_feature_states(
        %Environment{
          feature_states: fs,
          project: %Environment.Project{segments: segments}
        } = env,
        %Identity{identity_features: identity_features} = identity,
        override_traits \\ []
      ) do
    with identity <- Identity.set_env_key(identity, env),
         segment_features <-
           get_identity_applicable_segments(segments, identity, override_traits),
         prioritized <- clean_segments_by_priority(segment_features),
         replaced <- replace_segment_features(fs, prioritized),
         pre_features <- replace_identity_features(replaced, identity_features),
         final_features <- replace_multivariates(pre_features, identity) do
      final_features
    end
  end

  @doc """
  Get list of segments for a given `t:Flagsmith.Schemas.Identity.t/0` in a 
  given `t:Flagsmith.Schemas.Environment.t/0`.
  """
  @spec get_identity_segments(
          Environment.t(),
          Identity.t(),
          override_traits :: list(Traits.Trait.t())
        ) :: list(Segments.IdentitySegment.t())
  def get_identity_segments(
        %Environment{
          project: %Environment.Project{segments: segments}
        } = env,
        identity,
        override_traits \\ []
      ) do
    with identity <- Identity.set_env_key(identity, env),
         segments <-
           get_identity_applicable_segments(segments, identity, override_traits),
         replaced <- Enum.map(segments, &Segments.IdentitySegment.from_segment/1) do
      replaced
    end
  end

  defp clean_segments_by_priority(segments) do
    # keep an ordered table by index so we can retrieve the segments in order
    # at the end when manipulating them
    table_segments = :ets.new(:temp_segments, [:ordered_set])
    # keep a table to track the feature_states by name so we can compare in case
    # of same name fs_s
    table_features = :ets.new(:temp_track, [])

    # reduce through all the segments, using an accumulator for keeping track of the
    # current index of the segment
    Enum.reduce(segments, 0, fn %Segments.Segment{feature_states: segment_fs} = segment, index ->
      # for each segment, iterate through those segment's feature states
      Enum.each(segment_fs, fn %Environment.FeatureState{feature: feature} = fs ->
        # lookup on the tracking table if we have an item by the feature name
        case :ets.lookup(table_features, feature.name) do
          [] ->
            # if we don't, then we insert the initial one, keyed by name, and with
            # the full feature_state, index, and an empty list
            :ets.insert(table_features, {feature.name, fs, index, []})

          # if we do then we check if the existing one in the table is higher
          # priority than the current one being iterated
          [{_, %Environment.FeatureState{} = existing, existing_index, to_rem}] ->
            case Environment.FeatureState.is_higher_priority?(existing, fs) do
              true ->
                # if it's then it means the current one, will need to be removed
                # and as such we add the current iteration index to the list to
                # remove but keep the other tuple elements as they were
                :ets.insert(
                  table_features,
                  {feature.name, existing, existing_index, [index | to_rem]}
                )

              false ->
                # if it's not, then we re-insert the tuple (since it's a set table
                # using the same key, feature.name, will replace the existing item)
                # but we now substitute the feature state by the current one,
                # the index by the current one, and instead add the existing one
                # (the one that was previously the highest priority one) to the list
                # to be removed
                :ets.insert(
                  table_features,
                  {feature.name, fs, index, [existing_index | to_rem]}
                )
            end
        end
      end)

      # finally we insert the segment, keyed by the index (so it keeps the order as
      # the segments table is an ordered set), but unmodified
      # this first iteration is just to find any duplicate feature states
      :ets.insert(table_segments, {index, segment})

      # lastly we increment the accumulator by one so next iteration has the correct
      # index
      index + 1
    end)

    # now we convert the tracking table into a list and iterate through it
    :ets.tab2list(table_features)
    |> Enum.each(fn
      {_name, _, _index, []} ->
        # feature state has no conflicting states because the to remove list is empty
        :ok

      {name, %Environment.FeatureState{featurestate_uuid: uuid}, _index, to_remove} ->
        # since we have the name, full feature state, and a list of indexes
        # corresponding to segments that had this same feature state but with lower
        # priority we iterate the list of the indexes
        Enum.each(to_remove, fn index ->
          # we grab the segment for that index from the segments table
          [{_, %Segments.Segment{feature_states: segment_fs} = segment}] =
            :ets.lookup(table_segments, index)

          # and now we filter the feature states of that segment, that match the name
          # of this one, but not the uuid
          new_segment_fs =
            Enum.reject(segment_fs, fn %Environment.FeatureState{feature: feature} = fs ->
              feature.name == name && uuid != fs.featurestate_uuid
            end)

          # and lastly we replace that segment in the segments table, with the new
          # filtered feature states
          :ets.insert(table_segments, {index, %{segment | feature_states: new_segment_fs}})
        end)
    end)

    # lastly we fold through the right side (since it's an ordered set, it will go
    # from smaller to bigger, and folding through the right starts at the end)
    # and we just accumulate all segments into a new list
    # since the segments in this table were updated in the last step we get the
    # "cleaned" of duplicate features list of segments in the same original order
    final_segments =
      :ets.foldr(fn {_index, segment}, acc -> [segment | acc] end, [], table_segments)

    :ets.delete(table_segments)
    :ets.delete(table_features)

    final_segments
  end

  defp replace_multivariates(features, %Identity{} = identity) do
    features
    |> Enum.map(fn %{feature: %{type: type}} = feature_state ->
      case type do
        "MULTIVARIATE" ->
          uuid = Environment.FeatureState.get_hashing_id(feature_state)

          mv_fs = Map.get(feature_state, :multivariate_feature_state_values, [])

          percentage = percentage_from_ids([uuid, Identity.composite_key(identity)])

          case find_first_multivariate(mv_fs, percentage) do
            {:ok, new_value} -> %{feature_state | feature_state_value: new_value}
            _ -> feature_state
          end

        _ ->
          feature_state
      end
    end)
  end

  defp find_first_multivariate(mvs, percentage) do
    mvs
    |> Enum.sort_by(fn %{id: id, mv_fs_value_uuid: uuid} ->
      case id do
        nil -> uuid
        _ -> id
      end
    end)
    |> Enum.reduce_while(0, fn %{percentage_allocation: p_allot} = mv, start_perc ->
      limit = p_allot + start_perc

      case start_perc <= percentage and percentage < limit do
        true -> {:halt, Environment.FeatureState.extract_multivariate_value(mv)}
        _ -> {:cont, limit}
      end
    end)
  end

  @doc """
  Get feature state with a given feature_name for a given `t:Flagsmith.Schemas.Identity.t/0`
  and `t:Flagsmith.Schemas.Environment.t/0`.
  """
  @spec get_identity_feature_state(
          Environment.t(),
          Identity.t(),
          name :: String.t(),
          override_traits :: list(Traits.Trait.t())
        ) :: Environment.FeatureState.t() | nil
  def get_identity_feature_state(
        %Environment{} = env,
        %Identity{} = identity,
        name,
        override_traits \\ []
      ) do
    env
    |> get_identity_feature_states(identity, override_traits)
    |> Enum.find(fn %{feature: %{name: f_name}} -> f_name == name end)
  end

  @doc """
  Filters a list of segments accordingly to if they match an identity and traits
  (optionally using a list of traits to override those in the identity)
  """
  @spec get_identity_applicable_segments(
          segments :: list(Segments.Segment.t()),
          Identity.t(),
          override_traits :: list(Traits.Trait.t())
        ) :: list(Segments.Segment.t())
  def get_identity_applicable_segments(segments, identity, override_traits) do
    Enum.filter(segments, fn segment ->
      evaluate_identity_in_segment(identity, segment, override_traits)
    end)
  end

  @doc """
  Returns a list of `t:Flagsmith.Schemas.Environment.FeatureState.t/0` where any that
  has the same name as in the segments provided is replaced by the feature state there
  specified (if any).
  """
  @spec replace_segment_features(
          original :: list(Environment.FeatureState.t()),
          to_replace :: list(Segments.Segment.t())
        ) :: list(Environment.FeatureState.t())
  def replace_segment_features(original, to_replace) do
    Enum.reduce(to_replace, original, fn %{feature_states: segment_fs}, acc ->
      inverted = Enum.reverse(segment_fs)

      Enum.map(acc, fn %{feature: %{name: feature_name}} = flag ->
        Enum.find(inverted, flag, fn %{feature: %{name: replacement_name}} ->
          replacement_name == feature_name
        end)
      end)
    end)
  end

  @doc """
  Returns a list with elements of any of `t:Flagsmith.Schemas.Environment.FeatureState.t/0`
  or `t:Flagsmith.Schemas.Features.FeatureState.t/0` where any that has the same name
  as in any of the identity `t:Flagsmith.Schemas.Features.FeatureState.t/0` provided
  is replaced by that feature.
  """
  @spec replace_identity_features(
          original :: list(Environment.FeatureState.t()),
          to_replace :: list(Features.FeatureState.t())
        ) :: list(Environment.FeatureState.t() | Features.FeatureState.t())
  def replace_identity_features(original, to_replace) do
    Enum.reduce(to_replace, original, fn %{feature: %{name: replacement_name}} = replacement_flag,
                                         acc ->
      Enum.map(acc, fn %{feature: %{name: feature_name}} = flag ->
        case feature_name == replacement_name do
          true -> replacement_flag
          false -> flag
        end
      end)
    end)
  end

  @doc """
  True if an identity is deemed matching a segment conditions & rules, false otherwise.
  """
  @spec evaluate_identity_in_segment(Identity.t(), Segments.Segment.t(), list(Traits.Trait.t())) ::
          boolean()

  # if there's no rules we say it doesn't match but I'm not sure this is how it
  # should be
  def evaluate_identity_in_segment(_, %Segments.Segment{rules: []}, _),
    do: false

  def evaluate_identity_in_segment(
        %Identity{traits: identity_traits} = identity,
        %Segments.Segment{id: segment_id, rules: rules},
        override_traits
      ) do
    traits =
      case override_traits do
        [_ | _] -> override_traits
        _ -> identity_traits
      end

    Enum.all?(rules, fn rule ->
      traits_match_segment_rule(traits, rule, segment_id, Identity.composite_key(identity))
    end)
  end

  @doc """
  True if the segment rule conditions all match (or there's no conditions) and all
  nested rules too (or there's no rules), false otherwise.

  """
  @spec traits_match_segment_rule(
          list(Traits.Trait.t()),
          Segments.Segment.Rule.t(),
          non_neg_integer(),
          String.t()
        ) :: boolean()
  def traits_match_segment_rule(
        traits,
        %Segments.Segment.Rule{type: type, rules: rules, conditions: conditions},
        segment_id,
        identifier
      ) do
    matching_function = Types.Segment.Type.enum_matching_function(type)

    (length(conditions) == 0 or
       matching_function.(conditions, fn condition ->
         traits_match_segment_condition(
           traits,
           condition,
           segment_id,
           identifier
         )
       end)) and
      (length(rules) == 0 or
         matching_function.(rules, fn rule ->
           traits_match_segment_rule(traits, rule, segment_id, identifier)
         end))
  end

  @doc """
  True if according to the type of condition operator the comparison is true, false
  otherwise. With exception for PERCENTAGE_SPLIT operator all others are matched against
  the traits passed in.
  """
  @spec traits_match_segment_condition(
          list(Traits.Trait.t()),
          Segments.Segment.Condition.t(),
          non_neg_integer(),
          String.t()
        ) :: boolean()
  def traits_match_segment_condition(
        _traits,
        %Segments.Segment.Condition{operator: :PERCENTAGE_SPLIT, value: value},
        segment_id,
        identifier
      ) do
    with {_, {float, _}} <- {:float_parse, Float.parse(value)},
         {_, percentage} <-
           {:percentage, percentage_from_ids([segment_id, identifier], 1)} do
      percentage <= float
    else
      {_what, _} ->
        false
    end
  end

  def traits_match_segment_condition(
        traits,
        %Segments.Segment.Condition{operator: :IS_SET, property_: prop},
        _segment_id,
        _identifier
      ) do
    Enum.any?(traits, fn %Traits.Trait{trait_key: t_key} -> t_key == prop end)
  end

  def traits_match_segment_condition(
        traits,
        %Segments.Segment.Condition{operator: :IS_NOT_SET, property_: prop},
        _segment_id,
        _identifier
      ) do
    Enum.all?(traits, fn %Traits.Trait{trait_key: t_key} -> t_key != prop end)
  end

  def traits_match_segment_condition(
        traits,
        %Segments.Segment.Condition{operator: operator, value: value, property_: prop},
        _segment_id,
        _identifier
      ) do
    Enum.any?(traits, fn %Traits.Trait{
                           trait_key: t_key,
                           trait_value: t_value
                         } ->
      case prop == t_key do
        true -> trait_match(operator, value, t_value)
        _ -> false
      end
    end)
  end

  @doc """
  Given a list of ids in either and optionally a number of to duplicate them n times,
  compute a value representing a percentage to which those ids when hashed match.
  Refer to https://github.com/Flagsmith/flagsmith-engine/blob/c34b4baeea06d31d221433053b64c1e855fd8d4d/flag_engine/utils/hashing.py#L5
  """
  @spec percentage_from_ids(list(String.t() | non_neg_integer()), non_neg_integer()) :: float()
  def percentage_from_ids(original_ids, iterations \\ 1) do
    with {_, as_strings} <- {:strings, Enum.map(original_ids, &id_to_string/1)},
         {_, ids} <- {:ids, List.duplicate(as_strings, iterations)},
         {_, stringed} <- {:join, List.flatten(ids) |> Enum.join(",")},
         {_, hashed} <- {:hash, Flagsmith.Engine.HashingBehaviour.hash(stringed)},
         {_, {int, _}} <- {:int_parse, Integer.parse(hashed, 16)} do
      case Integer.mod(int, 9999) / 9998 * 100 do
        100.0 ->
          percentage_from_ids(original_ids, iterations + 1)

        percentage ->
          percentage
      end
    else
      {_, _} = error -> {:error, error}
    end
  end

  defp id_to_string(ids) when is_list(ids), do: Enum.map(ids, &id_to_string/1)
  defp id_to_string(int) when is_integer(int), do: Integer.to_string(int)
  defp id_to_string(bin) when is_binary(bin), do: bin
  defp id_to_string(atom) when is_atom(atom), do: Atom.to_string(atom)

  @doc """
  Given an `t:Flagsmith.Schemas.Types.Operator.t/0`, a cast or uncast segment value, and a cast trait 
  value, evaluate if the trait value matches to the segment value.
  """
  @spec trait_match(
          condition :: Types.Operator.t(),
          segment_value :: String.t() | Trait.Value.t(),
          trait :: Trait.Value.t()
        ) :: boolean()
  def trait_match(condition, %Trait.Value{type: :semver, value: value}, %Trait.Value{
        type: :semver,
        value: t_value
      }) do
    case Version.compare(t_value, value) do
      :gt -> condition in [:GREATER_THAN, :GREATER_THAN_INCLUSIVE, :NOT_EQUAL]
      :lt -> condition in [:LESS_THAN, :LESS_THAN_INCLUSIVE, :NOT_EQUAL]
      :eq -> condition in [:GREATER_THAN_INCLUSIVE, :LESS_THAN_INCLUSIVE, :EQUAL]
    end
  end

  def trait_match(:NOT_CONTAINS, %Trait.Value{type: :string, value: value}, %Trait.Value{
        type: :string,
        value: t_value
      }),
      do: not String.contains?(t_value, value)

  def trait_match(:CONTAINS, %Trait.Value{value: value}, %Trait.Value{value: t_value}),
    do: String.contains?(t_value, value)

  def trait_match(:REGEX, %Trait.Value{type: :string, value: value}, %Trait.Value{
        type: :string,
        value: t_value
      }) do
    case Regex.compile(value) do
      {:ok, regex} ->
        String.match?(t_value, regex)

      _ ->
        false
    end
  end

  def trait_match(:GREATER_THAN, %Trait.Value{value: value}, %Trait.Value{
        type: type,
        value: t_value
      }) do
    case type do
      :decimal -> Decimal.compare(t_value, value) == :gt
      _ -> t_value > value
    end
  end

  def trait_match(
        :GREATER_THAN_INCLUSIVE,
        %Trait.Value{value: value},
        %Trait.Value{type: type, value: t_value}
      ) do
    case type do
      :decimal -> Decimal.compare(t_value, value) in [:gt, :eq]
      _ -> t_value >= value
    end
  end

  def trait_match(:LESS_THAN, %Trait.Value{value: value}, %Trait.Value{type: type, value: t_value}) do
    case type do
      :decimal -> Decimal.compare(t_value, value) == :lt
      _ -> t_value < value
    end
  end

  def trait_match(:LESS_THAN_INCLUSIVE, %Trait.Value{value: value}, %Trait.Value{
        type: type,
        value: t_value
      }) do
    case type do
      :decimal -> Decimal.compare(t_value, value) in [:lt, :eq]
      _ -> t_value <= value
    end
  end

  def trait_match(:EQUAL, %Trait.Value{value: value}, %Trait.Value{type: type, value: t_value}) do
    case type do
      :decimal -> Decimal.equal?(t_value, value)
      _ -> t_value == value
    end
  end

  def trait_match(:NOT_EQUAL, %Trait.Value{value: value}, %Trait.Value{type: type, value: t_value}) do
    case type do
      :decimal -> not Decimal.equal?(t_value, value)
      _ -> t_value != value
    end
  end

  def trait_match(:IN, condition_value, %Trait.Value{type: :string, value: t_value}) do
    Enum.member?(String.split(condition_value, ","), t_value)
  end

  def trait_match(:IN, condition_value, %Trait.Value{
        type: :decimal,
        value: %Decimal{exp: 0} = t_value
      }) do
    Enum.member?(String.split(condition_value, ","), t_value |> Decimal.to_string())
  end

  def trait_match(:MODULO, condition_value, %Trait.Value{value: value}) do
    with true <- is_binary(condition_value),
         %Decimal{} <- value,
         [mod, result] <- String.split(condition_value, "|"),
         %Decimal{} = mod_val <- Decimal.new(mod),
         %Decimal{} = result_val <- Decimal.new(result),
         %Decimal{} = remainder <- Decimal.rem(value, mod_val) do
      Decimal.equal?(remainder, result_val)
    else
      _ ->
        false
    end
  rescue
    Decimal.Error ->
      Logger.warn(
        "invalid MODULO segment rule or trait value :: rule: #{inspect(condition_value)} :: value: #{inspect(value)}"
      )

      false
  end

  def trait_match(condition, not_cast, %Trait.Value{} = t_value_struct)
      when condition in @condition_operators and not is_struct(not_cast) and not is_map(not_cast) do
    case Trait.Value.is_semver(not_cast) do
      true ->
        {:ok, cast} = Trait.Value.create_semver(not_cast)
        {:ok, new_t_value_struct} = Trait.Value.create_semver(t_value_struct.value)

        trait_match(condition, cast, new_t_value_struct)

      false ->
        case cast_value(t_value_struct, not_cast) do
          {:ok, cast} ->
            trait_match(condition, cast, t_value_struct)

          _ ->
            false
        end
    end
  end

  def trait_match(_, _, _), do: false

  defp cast_value(%Trait.Value{} = trait_value, to_convert) do
    with {:ok, converted} <- Trait.Value.convert_value_to(trait_value, to_convert) do
      Trait.Value.cast(converted)
    end
  end
end