lib/json/ld/compaction.ex

defmodule JSON.LD.Compaction do
  @moduledoc """
  Implementation of the JSON-LD 1.0 Compaction Algorithms.
  """

  import JSON.LD.Utils

  alias JSON.LD.{Context, Options}

  # TODO: Why is dialyzer ignoring the spec of context as a potential binary and
  #  complains about the is_binary(context) in the final cond to never match?
  @dialyzer {:nowarn_function, compact: 3}
  @spec compact(map | [map], map | binary | nil, Options.convertible()) :: map
  def compact(input, context, options \\ %Options{}) do
    options = Options.new(options)
    active_context = JSON.LD.context(context, options)
    inverse_context = Context.inverse(active_context)
    expanded = JSON.LD.expand(input, options)

    result =
      case do_compact(expanded, active_context, inverse_context, nil, options.compact_arrays) do
        [] ->
          %{}

        result when is_list(result) ->
          # TODO: Spec fixme? We're setting vocab to true, as other implementations do it, but this is not mentioned in the spec
          %{compact_iri("@graph", active_context, inverse_context, nil, true) => result}

        result ->
          result
      end

    cond do
      Context.empty?(active_context) -> result
      is_binary(context) -> Map.put(result, "@context", context)
      true -> Map.put(result, "@context", context["@context"] || context)
    end
  end

  @spec do_compact(any, Context.t(), map, String.t() | nil, boolean) :: any
  defp do_compact(
         element,
         active_context,
         inverse_context,
         active_property,
         compact_arrays \\ true
       )

  # 1) If element is a scalar, it is already in its most compact form, so simply return element.
  defp do_compact(element, _, _, _, _)
       when is_binary(element) or is_number(element) or is_boolean(element),
       do: element

  # 2) If element is an array
  defp do_compact(element, active_context, inverse_context, active_property, compact_arrays)
       when is_list(element) do
    result =
      Enum.reduce(element, [], fn item, result ->
        case do_compact(item, active_context, inverse_context, active_property, compact_arrays) do
          nil -> result
          compacted_item -> [compacted_item | result]
        end
      end)
      |> Enum.reverse()

    if compact_arrays and length(result) == 1 and
         is_nil(
           (term_def = active_context.term_defs[active_property]) && term_def.container_mapping
         ) do
      List.first(result)
    else
      result
    end
  end

  # 3) Otherwise element is a JSON object.
  defp do_compact(element, active_context, inverse_context, active_property, compact_arrays)
       when is_map(element) do
    # 4)
    if Map.has_key?(element, "@value") or Map.has_key?(element, "@id") do
      result = compact_value(element, active_context, inverse_context, active_property)

      if scalar?(result) do
        result
      else
        do_compact_non_scalar(
          element,
          active_context,
          inverse_context,
          active_property,
          compact_arrays
        )
      end
    else
      do_compact_non_scalar(
        element,
        active_context,
        inverse_context,
        active_property,
        compact_arrays
      )
    end
  end

  @spec do_compact_non_scalar(any, Context.t(), map, String.t() | nil, boolean) :: any
  defp do_compact_non_scalar(
         element,
         active_context,
         inverse_context,
         active_property,
         compact_arrays
       ) do
    # 5)
    inside_reverse = active_property == "@reverse"
    # 6) + 7)
    element
    |> Enum.sort_by(fn {expanded_property, _} -> expanded_property end)
    |> Enum.reduce(%{}, fn {expanded_property, expanded_value}, result ->
      cond do
        # 7.1)
        expanded_property in ~w[@id @type] ->
          # 7.1.1)
          compacted_value =
            if is_binary(expanded_value) do
              compact_iri(
                expanded_value,
                active_context,
                inverse_context,
                nil,
                expanded_property == "@type"
              )

              # 7.1.2)
            else
              # 7.1.2.1)
              # TODO: RDF.rb calls also Array#compact
              if(is_list(expanded_value),
                do: expanded_value,
                else: [expanded_value]
              )
              # 7.1.2.2)
              |> Enum.reduce([], fn expanded_type, compacted_value ->
                compacted_value ++
                  [compact_iri(expanded_type, active_context, inverse_context, nil, true)]
              end)
              # 7.1.2.3)
              |> case do
                [compacted_value] -> compacted_value
                compacted_value -> compacted_value
              end
            end

          # 7.1.3)
          alias = compact_iri(expanded_property, active_context, inverse_context, nil, true)
          # 7.1.4)
          Map.put(result, alias, compacted_value)

        # 7.2)
        expanded_property == "@reverse" ->
          # 7.2.1)
          compacted_value =
            do_compact(expanded_value, active_context, inverse_context, "@reverse")

          # 7.2.2)
          {compacted_value, result} =
            Enum.reduce(compacted_value, {%{}, result}, fn {property, value},
                                                           {compacted_value, result} ->
              term_def = active_context.term_defs[property]
              # 7.2.2.1)
              if term_def && term_def.reverse_property do
                # 7.2.2.1.1)
                value =
                  if (!compact_arrays or term_def.container_mapping == "@set") and !is_list(value) do
                    [value]
                  else
                    value
                  end

                # 7.2.2.1.2) + 7.2.2.1.3)
                {compacted_value, merge_compacted_value(result, property, value)}
              else
                {Map.put(compacted_value, property, value), result}
              end
            end)

          # 7.2.3)
          unless Enum.empty?(compacted_value) do
            # 7.2.3.1)
            alias = compact_iri("@reverse", active_context, inverse_context, nil, true)
            # 7.2.3.2)
            Map.put(result, alias, compacted_value)
          else
            result
          end

        # 7.3)
        expanded_property == "@index" &&
          active_context.term_defs[active_property] &&
            active_context.term_defs[active_property].container_mapping == "@index" ->
          result

        # 7.4)
        expanded_property in ~w[@index @value @language] ->
          # 7.4.1)
          alias = compact_iri(expanded_property, active_context, inverse_context, nil, true)
          # 7.4.2)
          Map.put(result, alias, expanded_value)

        true ->
          # 7.5)
          result =
            if expanded_value == [] do
              # 7.5.1)
              item_active_property =
                compact_iri(
                  expanded_property,
                  active_context,
                  inverse_context,
                  expanded_value,
                  true,
                  inside_reverse
                )

              # 7.5.2)
              Map.update(result, item_active_property, [], fn
                value when not is_list(value) -> [value]
                value -> value
              end)
            else
              result
            end

          # 7.6)
          Enum.reduce(expanded_value, result, fn expanded_item, result ->
            # 7.6.1)
            item_active_property =
              compact_iri(
                expanded_property,
                active_context,
                inverse_context,
                expanded_item,
                true,
                inside_reverse
              )

            # 7.6.2)
            term_def = active_context.term_defs[item_active_property]
            container = (term_def && term_def.container_mapping) || nil

            # 7.6.3)
            value = (is_map(expanded_item) && expanded_item["@list"]) || expanded_item

            compacted_item =
              do_compact(
                value,
                active_context,
                inverse_context,
                item_active_property,
                compact_arrays
              )

            # 7.6.4)
            compacted_item =
              if list?(expanded_item) do
                # 7.6.4.1)
                compacted_item =
                  unless is_list(compacted_item), do: [compacted_item], else: compacted_item

                # 7.6.4.2)
                unless container == "@list" do
                  # 7.6.4.2.1)
                  compacted_item = %{
                    # TODO: Spec fixme? We're setting vocab to true, as other implementations do it, but this is not mentioned in the spec
                    compact_iri("@list", active_context, inverse_context, nil, true) =>
                      compacted_item
                  }

                  # 7.6.4.2.2)
                  if Map.has_key?(expanded_item, "@index") do
                    Map.put(
                      compacted_item,
                      # TODO: Spec fixme? We're setting vocab to true, as other implementations do it, but this is not mentioned in the spec
                      compact_iri("@index", active_context, inverse_context, nil, true),
                      expanded_item["@index"]
                    )
                  else
                    compacted_item
                  end

                  # 7.6.4.3)
                else
                  if Map.has_key?(result, item_active_property) do
                    raise JSON.LD.CompactionToListOfListsError,
                      message:
                        "The compacted document contains a list of lists as multiple lists have been compacted to the same term."
                  else
                    compacted_item
                  end
                end
              else
                compacted_item
              end

            # 7.6.5)
            if container in ~w[@language @index] do
              map_object = result[item_active_property] || %{}

              compacted_item =
                if container == "@language" and
                     is_map(compacted_item) and Map.has_key?(compacted_item, "@value"),
                   do: compacted_item["@value"],
                   else: compacted_item

              map_key = expanded_item[container]
              map_object = merge_compacted_value(map_object, map_key, compacted_item)
              Map.put(result, item_active_property, map_object)

              # 7.6.6)
            else
              compacted_item =
                if !is_list(compacted_item) and
                     (!compact_arrays or
                        container in ~w[@set @list] or expanded_property in ~w[@list @graph]),
                   do: [compacted_item],
                   else: compacted_item

              merge_compacted_value(result, item_active_property, compacted_item)
            end
          end)
      end
    end)
  end

  @spec merge_compacted_value(map, String.t(), any) :: map
  defp merge_compacted_value(map, key, value) do
    Map.update(map, key, value, fn
      old_value when is_list(old_value) and is_list(value) ->
        old_value ++ value

      old_value when is_list(old_value) ->
        old_value ++ [value]

      old_value when is_list(value) ->
        [old_value | value]

      old_value ->
        [old_value, value]
    end)
  end

  @doc """
  IRI Compaction

  Details at <https://www.w3.org/TR/json-ld-api/#iri-compaction>
  """
  @spec compact_iri(any, Context.t(), map, any | nil, boolean, boolean) :: any | nil
  def compact_iri(
        iri,
        active_context,
        inverse_context,
        value \\ nil,
        vocab \\ false,
        reverse \\ false
      )

  # 1) If iri is null, return null.
  def compact_iri(nil, _, _, _, _, _), do: nil

  def compact_iri(iri, active_context, inverse_context, value, vocab, reverse) do
    # 2) If vocab is true and iri is a key in inverse context:
    term =
      if vocab && Map.has_key?(inverse_context, iri) do
        # 2.1) Initialize default language to active context's default language, if it has one, otherwise to @none.
        # TODO: Spec fixme: This step is effectively useless; see Spec fixme on step 2.6.3
        # default_language = active_context.default_language || "@none"
        # 2.3) Initialize type/language to @language, and type/language value to @null. These two variables will keep track of the preferred type mapping or language mapping for a term, based on what is compatible with value.
        type_language = "@language"
        type_language_value = "@null"

        # 2.2) Initialize containers to an empty array. This array will be used to keep track of an ordered list of preferred container mappings for a term, based on what is compatible with value.
        # 2.4) If value is a JSON object that contains the key @index, then append the value @index to containers.
        containers = if index?(value), do: ["@index"], else: []

        {containers, type_language, type_language_value} =
          cond do
            # 2.5) If reverse is true, set type/language to @type, type/language value to @reverse, and append @set to containers.
            reverse ->
              containers = containers ++ ["@set"]
              type_language = "@type"
              type_language_value = "@reverse"
              {containers, type_language, type_language_value}

            # 2.6) Otherwise, if value is a list object, then set type/language and type/language value to the most specific values that work for all items in the list as follows:
            list?(value) ->
              # 2.6.1) If @index is a not key in value, then append @list to containers.
              containers = if not index?(value), do: containers ++ ["@list"], else: containers
              # 2.6.2) Initialize list to the array associated with the key @list in value.
              list = value["@list"]

              # 2.6.3) Initialize common type and common language to null. If list is empty, set common language to default language.
              # TODO: Spec fixme: Setting common language to default language is effectively useless, since the only place it is used is the follow loop in 2.6.4, which is immediately left when the list is empty
              {common_type, common_language} = {nil, nil}

              {type_language, type_language_value} =
                if Enum.empty?(list) do
                  {type_language, type_language_value}
                else
                  # 2.6.4) For each item in list:
                  {common_type, common_language} =
                    Enum.reduce_while(
                      list,
                      {common_type, common_language},
                      fn item, {common_type, common_language} ->
                        # 2.6.4.1) Initialize item language to @none and item type to @none.
                        {item_type, item_language} = {"@none", "@none"}
                        # 2.6.4.2) If item contains the key @value:
                        {item_type, item_language} =
                          if Map.has_key?(item, "@value") do
                            cond do
                              # 2.6.4.2.1) If item contains the key @language, then set item language to its associated value.
                              Map.has_key?(item, "@language") ->
                                {item_type, item["@language"]}

                              # 2.6.4.2.2) Otherwise, if item contains the key @type, set item type to its associated value.
                              Map.has_key?(item, "@type") ->
                                {item["@type"], item_language}

                              # 2.6.4.2.3) Otherwise, set item language to @null.
                              true ->
                                {item_type, "@null"}
                            end

                            # 2.6.4.3) Otherwise, set item type to @id.
                          else
                            {"@id", item_language}
                          end

                        common_language =
                          cond do
                            # 2.6.4.4) If common language is null, set it to item language.
                            is_nil(common_language) ->
                              item_language

                            # 2.6.4.5) Otherwise, if item language does not equal common language and item contains the key @value, then set common language to @none because list items have conflicting languages.
                            item_language != common_language and Map.has_key?(item, "@value") ->
                              "@none"

                            true ->
                              common_language
                          end

                        common_type =
                          cond do
                            # 2.6.4.6) If common type is null, set it to item type.
                            is_nil(common_type) ->
                              item_type

                            # 2.6.4.7) Otherwise, if item type does not equal common type, then set common type to @none because list items have conflicting types.
                            item_type != common_type ->
                              "@none"

                            true ->
                              common_type
                          end

                        # 2.6.4.8) If common language is @none and common type is @none, then stop processing items in the list because it has been detected that there is no common language or type amongst the items.
                        if common_language == "@none" and common_type == "@none" do
                          {:halt, {common_type, common_language}}
                        else
                          {:cont, {common_type, common_language}}
                        end
                      end
                    )

                  # 2.6.5) If common language is null, set it to @none.
                  common_language = if is_nil(common_language), do: "@none", else: common_language
                  # 2.6.6) If common type is null, set it to @none.
                  common_type = if is_nil(common_type), do: "@none", else: common_type

                  # 2.6.7) If common type is not @none then set type/language to @type and type/language value to common type.
                  if common_type != "@none" do
                    type_language = "@type"
                    type_language_value = common_type
                    {type_language, type_language_value}
                    # 2.6.8) Otherwise, set type/language value to common language.
                  else
                    type_language_value = common_language
                    {type_language, type_language_value}
                  end
                end

              {containers, type_language, type_language_value}

            # 2.7) Otherwise
            true ->
              # 2.7.1) If value is a value object:
              {containers, type_language, type_language_value} =
                if is_map(value) and Map.has_key?(value, "@value") do
                  # 2.7.1.1) If value contains the key @language and does not contain the key @index, then set type/language value to its associated value and append @language to containers.
                  if Map.has_key?(value, "@language") and not Map.has_key?(value, "@index") do
                    type_language_value = value["@language"]
                    containers = containers ++ ["@language"]
                    {containers, type_language, type_language_value}
                  else
                    # 2.7.1.2) Otherwise, if value contains the key @type, then set type/language value to its associated value and set type/language to @type.
                    if Map.has_key?(value, "@type") do
                      type_language_value = value["@type"]
                      type_language = "@type"
                      {containers, type_language, type_language_value}
                    else
                      {containers, type_language, type_language_value}
                    end
                  end

                  # 2.7.2) Otherwise, set type/language to @type and set type/language value to @id.
                else
                  type_language = "@type"
                  type_language_value = "@id"
                  {containers, type_language, type_language_value}
                end

              # 2.7.3) Append @set to containers.
              containers = containers ++ ["@set"]
              {containers, type_language, type_language_value}
          end

        # 2.8) Append @none to containers. This represents the non-existence of a container mapping, and it will be the last container mapping value to be checked as it is the most generic.
        containers = containers ++ ["@none"]

        # 2.9) If type/language value is null, set it to @null. This is the key under which null values are stored in the inverse context entry.
        type_language_value =
          if is_nil(type_language_value), do: "@null", else: type_language_value

        # 2.10) Initialize preferred values to an empty array. This array will indicate, in order, the preferred values for a term's type mapping or language mapping.
        preferred_values = []
        # 2.11) If type/language value is @reverse, append @reverse to preferred values.
        preferred_values =
          if type_language_value == "@reverse",
            do: preferred_values ++ ["@reverse"],
            else: preferred_values

        # 2.12) If type/language value is @id or @reverse and value has an @id member:
        preferred_values =
          if type_language_value in ~w[@id @reverse] and is_map(value) and
               Map.has_key?(value, "@id") do
            # 2.12.1) If the result of using the IRI compaction algorithm, passing active context, inverse context, the value associated with the @id key in value for iri, true for vocab, and true for document relative has a term definition in the active context with an IRI mapping that equals the value associated with the @id key in value, then append @vocab, @id, and @none, in that order, to preferred values.
            # TODO: Spec fixme? document_relative is not a specified parameter of compact_iri
            compact_id = compact_iri(value["@id"], active_context, inverse_context, nil, true)
            term_def = active_context.term_defs[compact_id]

            if term_def && term_def.iri_mapping == value["@id"] do
              preferred_values ++ ~w[@vocab @id @none]

              # 2.12.2) Otherwise, append @id, @vocab, and @none, in that order, to preferred values.
            else
              preferred_values ++ ~w[@id @vocab @none]
            end

            # 2.13) Otherwise, append type/language value and @none, in that order, to preferred values.
          else
            preferred_values ++ [type_language_value, "@none"]
          end

        # 2.14) Initialize term to the result of the Term Selection algorithm, passing inverse context, iri, containers, type/language, and preferred values.
        select_term(inverse_context, iri, containers, type_language, preferred_values)
      end

    cond do
      # 2.15) If term is not null, return term.
      not is_nil(term) ->
        term

      # 3) At this point, there is no simple term that iri can be compacted to. If vocab is true and active context has a vocabulary mapping:
      # 3.1) If iri begins with the vocabulary mapping's value but is longer, then initialize suffix to the substring of iri that does not match. If suffix does not have a term definition in active context, then return suffix.
      vocab && active_context.vocab && String.starts_with?(iri, active_context.vocab) ->
        suffix = String.replace_prefix(iri, active_context.vocab, "")

        if suffix != "" && is_nil(active_context.term_defs[suffix]) do
          String.replace_prefix(iri, active_context.vocab, "")
        else
          create_compact_iri(iri, active_context, value, vocab)
        end

      true ->
        create_compact_iri(iri, active_context, value, vocab)
    end
  end

  defp create_compact_iri(iri, active_context, value, vocab) do
    # 4) The iri could not be compacted using the active context's vocabulary mapping. Try to create a compact IRI, starting by initializing compact IRI to null. This variable will be used to tore the created compact IRI, if any.
    # 5) For each key term and value term definition in the active context:
    compact_iri =
      Enum.reduce(active_context.term_defs, nil, fn {term, term_def}, compact_iri ->
        cond do
          # 5.1) If the term contains a colon (:), then continue to the next term because terms with colons can't be used as prefixes.
          String.contains?(term, ":") ->
            compact_iri

          # 5.2) If the term definition is null, its IRI mapping equals iri, or its IRI mapping is not a substring at the beginning of iri, the term cannot be used as a prefix because it is not a partial match with iri. Continue with the next term.
          is_nil(term_def) || term_def.iri_mapping == iri ||
              not String.starts_with?(iri, term_def.iri_mapping) ->
            compact_iri

          true ->
            # 5.3) Initialize candidate by concatenating term, a colon (:), and the substring of iri that follows after the value of the term definition's IRI mapping.
            candidate =
              term <>
                ":" <> (String.split_at(iri, String.length(term_def.iri_mapping)) |> elem(1))

            # 5.4) If either compact IRI is null or candidate is shorter or the same length but lexicographically less than compact IRI and candidate does not have a term definition in active context or if the term definition has an IRI mapping that equals iri and value is null, set compact IRI to candidate.
            # TODO: Spec fixme: The specified expression is pretty ambiguous without brackets ...
            # TODO: Spec fixme: "if the term definition has an IRI mapping that equals iri" is already catched in 5.2, so will never happen here ...
            if (is_nil(compact_iri) or shortest_or_least?(candidate, compact_iri)) and
                 (is_nil(active_context.term_defs[candidate]) or
                    (active_context.term_defs[candidate].iri_mapping == iri and is_nil(value))) do
              candidate
            else
              compact_iri
            end
        end
      end)

    cond do
      # 6) If compact IRI is not null, return compact IRI.
      not is_nil(compact_iri) ->
        compact_iri

      # 7) If vocab is false then transform iri to a relative IRI using the document's base IRI.
      not vocab ->
        remove_base(iri, Context.base(active_context))

      # 8) Finally, return iri as is.
      true ->
        iri
    end
  end

  @spec shortest_or_least?(String.t(), String.t()) :: boolean
  defp shortest_or_least?(a, b) do
    (a_len = String.length(a)) < (b_len = String.length(b)) or
      (a_len == b_len and a < b)
  end

  @spec remove_base(String.t(), String.t() | nil) :: String.t()
  defp remove_base(iri, nil), do: iri

  defp remove_base(iri, base) do
    base_len = String.length(base)

    if String.starts_with?(iri, base) and String.at(iri, base_len) in ~w(? #) do
      String.split_at(iri, base_len) |> elem(1)
    else
      case URI.parse(base) do
        %URI{path: nil} -> iri
        base -> do_remove_base(iri, %URI{base | path: parent_path(base.path)}, 0)
      end
    end
  end

  @spec do_remove_base(String.t(), URI.t(), non_neg_integer) :: String.t()
  defp do_remove_base(iri, base, index) do
    base_str = URI.to_string(base)

    cond do
      String.starts_with?(iri, base_str) ->
        case String.duplicate("../", index) <>
               (String.split_at(iri, String.length(base_str)) |> elem(1)) do
          "" -> "./"
          rel -> rel
        end

      base.path == "/" ->
        iri

      true ->
        do_remove_base(iri, %URI{base | path: parent_path(base.path)}, index + 1)
    end
  end

  @spec parent_path(String.t()) :: String.t()
  defp parent_path("/"), do: "/"

  defp parent_path(path) do
    case Path.dirname(String.trim_trailing(path, "/")) do
      "/" -> "/"
      parent -> parent <> "/"
    end
  end

  @doc """
  Value Compaction

  Details at <https://www.w3.org/TR/json-ld-api/#value-compaction>
  """
  @spec compact_value(any, Context.t(), map, String.t()) :: any
  def compact_value(value, active_context, inverse_context, active_property) do
    term_def = active_context.term_defs[active_property]
    # 1) Initialize number members to the number of members value contains.
    number_members = Enum.count(value)

    # 2) If value has an @index member and the container mapping associated to active property is set to @index, decrease number members by 1.
    number_members =
      if term_def != nil and Map.has_key?(value, "@index") and
           term_def.container_mapping == "@index",
         do: number_members - 1,
         else: number_members

    # 3) If number members is greater than 2, return value as it cannot be compacted.
    unless number_members > 2 do
      {type_mapping, language_mapping} =
        if term_def,
          do: {term_def.type_mapping, term_def.language_mapping},
          else: {nil, nil}

      cond do
        # 4) If value has an @id member
        id = Map.get(value, "@id") ->
          cond do
            # 4.1) If number members is 1 and the type mapping of active property is set to @id, return the result of using the IRI compaction algorithm, passing active context, inverse context, and the value of the @id member for iri.
            number_members == 1 and type_mapping == "@id" ->
              compact_iri(id, active_context, inverse_context)

            # 4.2) Otherwise, if number members is 1 and the type mapping of active property is set to @vocab, return the result of using the IRI compaction algorithm, passing active context, inverse context, the value of the @id member for iri, and true for vocab.
            number_members == 1 and type_mapping == "@vocab" ->
              compact_iri(id, active_context, inverse_context, nil, true)

            # 4.3) Otherwise, return value as is.
            true ->
              value
          end

        # 5) Otherwise, if value has an @type member whose value matches the type mapping of active property, return the value associated with the @value member of value.
        (type = Map.get(value, "@type")) && type == type_mapping ->
          value["@value"]

        # 6) Otherwise, if value has an @language member whose value matches the language mapping of active property, return the value associated with the @value member of value.
        # TODO: Spec fixme: doesn't specify to check default language as well
        (language = Map.get(value, "@language")) &&
            language in [language_mapping, active_context.default_language] ->
          value["@value"]

        true ->
          # 7) Otherwise, if number members equals 1 and either the value of the @value member is not a string, or the active context has no default language, or the language mapping of active property is set to null,, return the value associated with the @value member.
          value_value = value["@value"]
          # TODO: Spec fixme: doesn't specify to check default language as well
          if number_members == 1 and
               (not is_binary(value_value) or
                  !active_context.default_language or
                  Context.language(active_context, active_property) == nil) do
            value_value
            # 8) Otherwise, return value as is.
          else
            value
          end
      end
    else
      value
    end
  end

  @doc """
  Term Selection

  Details at <https://www.w3.org/TR/json-ld-api/#term-selection>
  """
  @spec select_term(map, String.t(), [String.t()], String.t(), [String.t()]) :: String.t()
  def select_term(inverse_context, iri, containers, type_language, preferred_values) do
    container_map = inverse_context[iri]

    Enum.find_value(containers, fn container ->
      if type_language_map = container_map[container] do
        value_map = type_language_map[type_language]
        Enum.find_value(preferred_values, fn item -> value_map[item] end)
      end
    end)
  end
end