lib/localized_routes/scopes.ex

defmodule PhxLocalizedRoutes.Scope.Nested do
  @moduledoc """
  Struct for scope with optionally nested scopes
  """
  @type t :: %__MODULE__{
          assign: %{atom => any} | nil,
          scope_path: list(binary),
          scope_prefix: binary,
          scope_alias: atom,
          scopes: %{(binary | atom) => t} | nil
        }

  defstruct [
    :scope_alias,
    assign: %{},
    scope_path: [],
    scope_prefix: "",
    scopes: %{}
  ]
end

defmodule PhxLocalizedRoutes.Scope.Flat do
  @moduledoc """
  Struct for flattened scope
  """
  @type t :: %__MODULE__{
          assign: %{atom => any} | nil,
          scope_path: list(binary),
          scope_prefix: binary,
          scope_alias: atom
        }

  defstruct [
    :scope_alias,
    assign: %{},
    scope_path: [],
    scope_prefix: ""
  ]
end

defmodule PhxLocalizedRoutes.Scopes do
  @moduledoc false

  alias PhxLocalizedRoutes.Scope

  @type scopes :: %{(binary | nil) => Scope.Flat.t()}
  @type scopes_nested :: %{(binary | nil) => Scope.Nested.t()}
  @type scopes_nested_tuple :: {binary | nil, Scope.Nested.t()}
  @type scope_nested :: Scope.Nested.t()
  @type scope_tuple :: {binary | nil, Scope.Flat.t()}
  @type opts_scope :: PhxLocalizedRoutes.opts_scope()

  # return a list of unique values assigned to given key. Returns a list
  # of tuples with unique combinations when a list of keys is given.
  @spec assigned_values(scopes, atom | binary) :: list(any)
  def assigned_values(scopes, key) when is_atom(key) or is_binary(key),
    do: scopes |> assigned_values([key]) |> Stream.map(&elem(&1, 0)) |> Enum.uniq()

  @spec assigned_values(scopes, list(atom | binary)) :: list({atom | binary, any})
  def assigned_values(scopes, keys) when is_list(keys) do
    scopes |> aggregate_assigns(keys) |> Enum.uniq()
  end

  # takes a nested map of maps and returns a flat map with concatenated keys, aliases and prefixes.
  @spec flatten(scopes :: scopes_nested) :: scopes()
  def flatten(scopes), do: scopes |> do_flatten_scopes() |> List.flatten() |> Map.new()

  @spec do_flatten_scopes(scopes_nested, nil | {binary, any} | {nil, nil}) ::
          list(scopes)
  def do_flatten_scopes(scopes, parent \\ {nil, nil}) do
    Enum.reduce(scopes, [], fn
      {_, scope_opts} = full_scope, acc ->
        new_scope = flatten_scope(full_scope, parent)
        flattened_subtree = do_flatten_scopes(scope_opts.scopes, new_scope)

        [[new_scope | flattened_subtree] | acc]
    end)
  end

  @spec flatten_scope(scopes_nested_tuple(), scope_tuple) :: scope_tuple
  def flatten_scope({_scope, scope_opts}, {_p_scope, p_scope_opts})
      when is_nil(p_scope_opts) or is_nil(p_scope_opts.scope_alias) do
    scope_opts = Map.drop(scope_opts, [:scopes])
    scope_key = scope_opts.assign.scope_helper
    {scope_key, struct(Scope.Flat, Map.from_struct(scope_opts))}
  end

  def flatten_scope({_scope, scope_opts}, {_p_scope, p_scope_opts}) do
    flattened_scope_prefix = Path.join(p_scope_opts.scope_prefix, scope_opts.scope_prefix)

    flattened_scope_alias =
      String.to_atom(
        "#{Atom.to_string(p_scope_opts.scope_alias)}_#{Atom.to_string(scope_opts.scope_alias)}"
      )

    scope_opts = %{
      scope_opts
      | scope_prefix: flattened_scope_prefix,
        scope_alias: flattened_scope_alias
    }

    new_scope_opts = Map.drop(scope_opts, [:scopes])
    new_scope_key = scope_opts.assign.scope_helper

    {new_scope_key, struct(Scope.Flat, Map.from_struct(new_scope_opts))}
  end

  @spec get_slug_key(binary) :: binary
  def get_slug_key(slug), do: slug |> String.replace("/", "_") |> String.replace_prefix("_", "")

  @spec get_scope_meta(slug :: binary, list(binary)) :: %{
          key: nil | binary,
          path: list,
          prefix: binary,
          alias: nil | atom,
          helper: nil | binary
        }
  def get_scope_meta("/", []) do
    %{key: nil, path: [], prefix: "", alias: nil, helper: nil}
  end

  def get_scope_meta(slug, p_scope_path) when is_list(p_scope_path) do
    key = get_slug_key(slug)
    path = Enum.concat(p_scope_path, [key])

    %{
      key: key,
      path: path,
      prefix: key,
      alias: String.to_atom(key),
      helper: Enum.join(path, "_")
    }
  end

  @spec add_precomputed_values!(%{binary => opts_scope}, parent_scope :: scope_nested) ::
          scopes_nested
  def add_precomputed_values!(scopes, p_scope \\ %Scope.Nested{}) do
    for {slug, scope} <- scopes, into: %{} do
      scope = Map.put_new(scope, :assign, Map.new())
      scope_meta = get_scope_meta(slug, p_scope.scope_path)
      assign_map = destruct(scope.assign)

      new_assign =
        p_scope.assign
        |> Map.merge(assign_map)
        |> Map.put(:scope_helper, scope_meta.helper)

      new_opts =
        maybe_compute_nested_scopes(
          %Scope.Nested{
            assign: new_assign,
            scope_path: scope_meta.path,
            scope_prefix: Path.join("/", scope_meta.prefix),
            scope_alias: scope_meta.alias
          },
          scope
        )

      {scope_meta.key, new_opts}
    end
  end

  @spec destruct(map | struct) :: map
  def destruct(map_or_struct) when is_struct(map_or_struct), do: Map.from_struct(map_or_struct)
  def destruct(map_or_struct) when is_map(map_or_struct), do: map_or_struct

  @spec maybe_compute_nested_scopes(scope_nested, opts_scope) :: scope_nested
  def maybe_compute_nested_scopes(
        %Scope.Nested{} = scope_struct,
        %{scopes: scopes} = _scope_map
      ),
      do: Map.put(scope_struct, :scopes, add_precomputed_values!(scopes, scope_struct))

  def maybe_compute_nested_scopes(%Scope.Nested{} = scope_struct, %{} = _scope_map),
    do: scope_struct

  @spec aggregate_assigns(scopes, list(binary | atom), list) :: list
  def aggregate_assigns(scopes, keys, acc \\ []) do
    scopes
    |> Enum.reduce(acc, fn
      {_slug, %{assign: assign}}, acc ->
        [get_values(assign, keys) | acc]
    end)
    |> List.flatten()
    |> Enum.uniq()
  end

  @spec get_values(map, list(binary | atom)) :: tuple
  defp get_values(assign, keys),
    do: List.to_tuple(for(key <- keys, do: Map.get(assign, key)))
end