defmodule Dx.Parser do
  # Receives the module attributes created by the macros in `Dx.DSL`
  # and converts them into normalized `aliases` and lists of `Dx.Rule` structs.

  @moduledoc false

  defmodule Token do
    # Encapsulates all static args to be passed around between functions.

    @moduledoc false

    defstruct [:type, aliases: %{}, opts: %{}]

    @type t() :: %__MODULE__{
            type: module(),
            aliases: %{atom() => any()},
            opts: map()
          }

    def with_opts(token, opts), do: %{token | opts: Map.new(opts)}

    def set_aliases(token, new_aliases) do
      Map.update!(token, :aliases, fn aliases ->
        Enum.reduce(new_aliases, aliases, fn
          {key, nil}, aliases -> Map.delete(aliases, key)
          {key, val}, aliases -> Map.put(aliases, key, val)
        end)
      end)
    end
  end

  @option_keys [:when, :then]

  def normalize_aliases(aliases) do
    aliases
    |> List.flatten()
    |> Map.new(fn {key, val} -> {key, List.wrap(val)} end)
  end

  @doc """
  Entry point for this module
  """
  def parse(directives, token) do
    directives
    |> Enum.reduce({[], token}, fn
      {:infer_alias, aliases}, {rules, token} ->
        new_token = Token.set_aliases(token, aliases)
        {rules, new_token}

      directive, {rules, token} ->
        new_rules = directive_to_rules(directive, token)
        {Enum.reverse(new_rules) ++ rules, token}
    end)
    |> elem(0)
    |> Enum.reverse()
  end

  # def directive_to_rules({:infer_alias, opts}, token) do
  #   to_aliased_rules(%{key => true}, Token.with_opts(token, opts))
  # end

  def directive_to_rules({:infer, key, opts}, token) when is_atom(key) do
    to_aliased_rules(%{key => true}, Token.with_opts(token, opts))
  end

  def directive_to_rules({:infer, key}, token) when is_atom(key) do
    to_aliased_rules(%{key => true}, token)
  end

  def directive_to_rules({:infer, then, opts}, token) when is_list(then) do
    then
    |> Map.new(fn
      key when is_atom(key) -> {key, true}
      {key, val} -> {key, val}
    end)
    |> to_aliased_rules(Token.with_opts(token, opts))
  end

  def directive_to_rules({:infer, then, opts}, token) when is_map(then) do
    to_aliased_rules(then, Token.with_opts(token, opts))
  end

  def directive_to_rules({:infer, opts}, token) when is_list(opts) do
    {assigns, opts} = split_assigns(%{}, opts)
    to_aliased_rules(assigns, Token.with_opts(token, opts))
  end

  def directive_to_rules({:import, mod, opts}, _token) when is_list(opts) do
    rules = mod.dx_rules()

    case opts do
      [] -> rules
      [only: keys] -> Enum.filter(rules, &(&1.key in keys))
      [except: keys] -> Enum.reject(rules, &(&1.key in keys))
    end
  end

  defp split_assigns(assigns, []), do: {assigns, []}
  defp split_assigns(assigns, opts = [{key, _} | _]) when key in @option_keys, do: {assigns, opts}

  defp split_assigns(assigns, [key | opts]) when is_atom(key),
    do: assigns |> Map.put(key, true) |> split_assigns(opts)

  defp split_assigns(assigns, [{key, val} | opts]),
    do: assigns |> Map.put(key, val) |> split_assigns(opts)

  defp to_aliased_rules(assigns, token) do
    Enum.flat_map(assigns, &to_rules(&1, token))
  end

  defp to_rules({key, val}, token) do
    Map.get(token.aliases, key, [key])
    |> Enum.map(&to_rule(&1, val, token))
  end

  defp to_rule(key, val, token) do
    attrs = Map.merge(token.opts, %{key: key, val: val, type: token.type})

    struct!(Dx.Rule, attrs)
    |> Map.update!(:when, &normalize_condition/1)
    |> Map.update!(:when, &replace_aliases(&1, token.aliases))
    |> Map.update!(:val, &replace_aliases(&1, token.aliases))
  end

  defp normalize_condition(atom) when is_atom(atom), do: %{atom => true}
  defp normalize_condition(condition), do: condition

  defp replace_aliases(%type{} = struct, aliases) do
    fields =
      struct
      |> Map.from_struct()
      |> replace_aliases(aliases)

    struct(type, fields)
  end

  defp replace_aliases(map, aliases) when is_map(map) do
    Map.new(map, fn {key, val} ->
      {replace_aliases(key, aliases), replace_aliases(val, aliases)}
    end)
  end

  defp replace_aliases({:all, conditions}, aliases) do
    aliased =
      Enum.map(conditions, fn
        {key, val} -> {replace_aliases(key, aliases), replace_aliases(val, aliases)}
        condition -> replace_aliases(condition, aliases)
      end)

    {:all, aliased}
  end

  defp replace_aliases(list, aliases) when is_list(list) do
    Enum.map(list, &replace_aliases(&1, aliases))
  end

  defp replace_aliases(key, aliases) when is_map_key(aliases, key) do
    Map.get(aliases, key)
  end

  defp replace_aliases(other, _aliases) do
    other
  end
end
