defmodule Spark.InfoGenerator do
  @moduledoc """
  Used to dynamically generate configuration functions for Spark extensions
  based on their DSL.

  ## Usage

  ```elixir
  defmodule MyConfig do
    use Spark.InfoGenerator, extension: MyDslExtension, sections: [:my_section]
  end
  ```
  """

  @type options :: [{:extension, module} | {:sections, [atom]}]

  @doc false
  @spec __using__(options) :: Macro.t()
  defmacro __using__(opts) do
    extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__)
    sections = Keyword.get(opts, :sections, [])

    quote do
      require Spark.InfoGenerator
      require unquote(extension)

      Spark.InfoGenerator.generate_config_functions(
        unquote(extension),
        unquote(sections)
      )

      Spark.InfoGenerator.generate_options_functions(
        unquote(extension),
        unquote(sections)
      )

      Spark.InfoGenerator.generate_entity_functions(
        unquote(extension),
        unquote(sections)
      )
    end
  end

  @doc """
  Given an extension and a list of DSL sections, generate an options function
  which returns a map of all configured options for a resource (including
  defaults).
  """
  @spec generate_options_functions(module, [atom]) :: Macro.t()
  defmacro generate_options_functions(extension, sections) do
    for {path, options} <- extension_sections_to_option_list(extension, sections) do
      function_name = :"#{Enum.join(path, "_")}_options"

      quote location: :keep do
        @doc """
        #{unquote(Enum.join(path, "."))} DSL options

        Returns a map containing the and any configured or default values.
        """
        @spec unquote(function_name)(dsl_or_extended :: module | map) :: %{required(atom) => any}
        def unquote(function_name)(dsl_or_extended) do
          import Spark.Dsl.Extension, only: [get_opt: 4]

          unquote(Macro.escape(options))
          |> Stream.map(fn option ->
            value =
              dsl_or_extended
              |> get_opt(option.path, option.name, Map.get(option, :default))

            {option.name, value}
          end)
          |> Stream.reject(&is_nil(elem(&1, 1)))
          |> Map.new()
        end

        defoverridable [{unquote(function_name), 1}]
      end
    end
  end

  @doc """
  Given an extension and a list of DSL sections, generate an entities function
  which returns a list of entities.
  """
  @spec generate_entity_functions(module, [atom]) :: Macro.t()
  defmacro generate_entity_functions(extension, sections) do
    entity_paths =
      extension.sections()
      |> Stream.filter(&(&1.name in sections))
      |> Stream.flat_map(&explode_section([], &1))
      |> Stream.filter(fn {_, section} -> section.patchable? || Enum.any?(section.entities) end)
      |> Stream.map(&elem(&1, 0))

    for path <- entity_paths do
      function_name = path |> Enum.join("_") |> String.to_atom()

      quote location: :keep do
        @doc """
        #{unquote(Enum.join(path, "."))} DSL entities
        """
        @spec unquote(function_name)(dsl_or_extended :: module | map) :: [struct]
        def unquote(function_name)(dsl_or_extended) do
          import Spark.Dsl.Extension, only: [get_entities: 2]

          get_entities(dsl_or_extended, unquote(path))
        end

        defoverridable [{unquote(function_name), 1}]
      end
    end
  end

  @doc """
  Given an extension and a list of DSL sections generate individual config
  functions for each option.
  """
  @spec generate_config_functions(module, [atom]) :: Macro.t()
  defmacro generate_config_functions(extension, sections) do
    for {_, options} <- extension_sections_to_option_list(extension, sections) do
      for option <- options do
        generate_config_function(option)
      end
    end
  end

  defp explode_section(path, %{sections: [], name: name} = section),
    do: [{path ++ [name], section}]

  defp explode_section(path, %{sections: sections, name: name} = section) do
    path = path ++ [name]

    head = [{path, section}]
    tail = Stream.flat_map(sections, &explode_section(path, &1))

    Stream.concat(head, tail)
  end

  # sobelow_skip ["DOS.StringToAtom"]
  defp extension_sections_to_option_list(extension, sections) do
    extension.sections()
    |> Stream.filter(&(&1.name in sections))
    |> Stream.flat_map(&explode_section([], &1))
    |> Stream.reject(fn {_, section} -> Enum.empty?(section.schema) end)
    |> Stream.map(fn {path, section} ->
      schema =
        section.schema
        |> Enum.map(fn {name, opts} ->
          opts
          |> Map.new()
          |> Map.take(~w[type doc default]a)
          |> Map.update!(:type, &spec_for_type(&1, opts))
          |> Map.put(:pred?, name |> to_string() |> String.ends_with?("?"))
          |> Map.put(:name, name)
          |> Map.put(:path, path)
          |> Map.put(
            :function_name,
            path
            |> Enum.concat([name])
            |> Enum.join("_")
            |> String.trim_trailing("?")
            |> String.to_atom()
          )
        end)

      {path, schema}
    end)
    |> Map.new()
  end

  # sobelow_skip ["DOS.BinToAtom"]
  defp generate_config_function(%{pred?: true} = option) do
    function_name = :"#{option.function_name}?"

    quote location: :keep do
      @doc unquote(option.doc)
      @spec unquote(function_name)(dsl_or_extended :: module | map) ::
              unquote(option.type)
      def unquote(function_name)(dsl_or_extended) do
        import Spark.Dsl.Extension, only: [get_opt: 4]

        get_opt(
          dsl_or_extended,
          unquote(option.path),
          unquote(option.name),
          unquote(Macro.escape(option.default))
        )
      end

      defoverridable [{unquote(function_name), 1}]
    end
  end

  # sobelow_skip ["DOS.BinToAtom"]
  defp generate_config_function(option) do
    option = Map.put_new(option, :default, nil)

    quote location: :keep do
      @doc unquote(Map.get(option, :doc, false))
      @spec unquote(option.function_name)(dsl_or_extended :: module | map) ::
              {:ok, unquote(option.type)} | :error

      if unquote(is_nil(option.default)) do
        def unquote(option.function_name)(dsl_or_extended) do
          import Spark.Dsl.Extension, only: [fetch_opt: 3]

          case fetch_opt(
                 dsl_or_extended,
                 unquote(option.path),
                 unquote(option.name)
               ) do
            :error -> :error
            {:ok, value} -> {:ok, value}
          end
        end
      else
        def unquote(option.function_name)(dsl_or_extended) do
          import Spark.Dsl.Extension, only: [fetch_opt: 3]

          case fetch_opt(
                 dsl_or_extended,
                 unquote(option.path),
                 unquote(option.name)
               ) do
            :error -> {:ok, unquote(Macro.escape(option.default))}
            {:ok, value} -> {:ok, value}
          end
        end
      end

      @doc unquote(Map.get(option, :doc, false))
      @spec unquote(:"#{option.function_name}!")(dsl_or_extended :: module | map) ::
              unquote(option.type) | no_return
      if unquote(is_nil(option.default)) do
        # sobelow_skip ["DOS.BinToAtom"]
        def unquote(:"#{option.function_name}!")(dsl_or_extended) do
          import Spark.Dsl.Extension, only: [fetch_opt: 3, get_persisted: 2]

          case fetch_opt(
                 dsl_or_extended,
                 unquote(option.path),
                 unquote(option.name)
               ) do
            :error ->
              on = get_persisted(dsl_or_extended, :module)
              raise "No configuration for `#{unquote(option.name)}` present on #{inspect(on)}"

            {:ok, value} ->
              value
          end
        end
      else
        # sobelow_skip ["DOS.BinToAtom"]
        def unquote(:"#{option.function_name}!")(dsl_or_extended) do
          import Spark.Dsl.Extension, only: [fetch_opt: 3, get_persisted: 2]

          case fetch_opt(
                 dsl_or_extended,
                 unquote(option.path),
                 unquote(option.name)
               ) do
            :error ->
              unquote(Macro.escape(option.default))

            {:ok, value} ->
              value
          end
        end
      end

      defoverridable [
        {unquote(option.function_name), 1},
        {unquote(:"#{option.function_name}!"), 1}
      ]
    end
  end

  def spec_for_type(:keyword_list, opts) do
    if opts[:keys] do
      value_type =
        opts[:keys]
        |> Enum.map(fn {key, spec} ->
          key_spec =
            case key do
              :* -> spec_for_type(:atom, [])
              other -> other
            end

          {key_spec, spec_for_type(spec[:type], spec)}
        end)
        |> Enum.uniq()
        |> Enum.reduce(nil, fn
          type, nil ->
            type

          type, other_type ->
            {:|, [], [type, other_type]}
        end)

      [value_type]
    else
      quote do
        keyword()
      end
    end
  end

  # TODO: this should be combined with `Spark.Options.Docs.type_to_spec
  def spec_for_type({:literal, value}, _opts) when is_atom(value) or is_integer(value) do
    value
  end

  def spec_for_type({:literal, value}, _opts) when is_tuple(value) do
    value
    |> Tuple.to_list()
    |> Enum.map(&spec_for_type({:literal, &1}, []))
    |> List.to_tuple()
  end

  def spec_for_type({:literal, value}, _opts) when is_list(value) do
    Enum.map(value, &spec_for_type({:literal, &1}, []))
  end

  def spec_for_type({:literal, _value}, _opts) do
    {:any, [], Elixir}
  end

  def spec_for_type({:behaviour, _module}, _opts), do: {:module, [], Elixir}

  def spec_for_type({:spark, _}, _opts), do: {:module, [], Elixir}

  def spec_for_type({:spark_function_behaviour, behaviour, {_, arity}}, _opts),
    do:
      spec_for_type(
        {:or,
         [
           {:behaviour, behaviour},
           {{:behaviour, behaviour}, {:keyword, [], Elixir}},
           {:fun, arity}
         ]},
        []
      )

  def spec_for_type({:fun, arity}, _opts) do
    args = List.duplicate({:any, [], Elixir}, arity)

    [{:->, [], [args, {:any, [], Elixir}]}]
  end

  def spec_for_type({:or, [type]}, _opts), do: spec_for_type(type, [])

  def spec_for_type({:or, [next | remaining]}, _opts),
    do: {:|, [], [spec_for_type(next, []), spec_for_type({:or, remaining}, [])]}

  def spec_for_type({:in, %Range{first: first, last: last}}, _opts)
      when is_integer(first) and is_integer(last),
      do: {:.., [], [first, last]}

  def spec_for_type({:in, %Range{first: first, last: last}}, _opts),
    do:
      {{:., [], [{:__aliases__, [], [:Range]}, :t]}, [],
       [spec_for_type(first, []), spec_for_type(last, [])]}

  def spec_for_type({:in, values}, _opts) do
    values
    |> Enum.map(&spec_for_type({:literal, &1}, []))
    |> case do
      [] ->
        {:any, [], Elixir}

      specs ->
        Enum.reduce(specs, nil, fn
          spec, nil ->
            spec

          spec, other_specs ->
            {:|, [], [spec, other_specs]}
        end)
    end
  end

  def spec_for_type({type, subtype}, _opts) when type in [:list, :wrap_list],
    do: [spec_for_type(subtype, [])]

  def spec_for_type({:custom, _, _, _}, _opts), do: spec_for_type(:any, [])

  def spec_for_type({:tuple, subtypes}, _opts) do
    subtypes
    |> Enum.map(&spec_for_type(&1, []))
    |> List.to_tuple()
  end

  def spec_for_type(:quoted, _opts) do
    {:any, [], Elixir}
  end

  def spec_for_type(:string, _opts),
    do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}

  def spec_for_type(:literal, _opts) do
    {:any, [], Elixir}
  end

  def spec_for_type(terminal, _opts)
      when terminal in ~w[any map atom string boolean integer non_neg_integer pos_integer float timeout pid reference mfa]a,
      do: {terminal, [], Elixir}

  def spec_for_type(atom, _opts) when is_atom(atom), do: atom
  def spec_for_type(number, _opts) when is_number(number), do: number
  def spec_for_type(string, _opts) when is_binary(string), do: spec_for_type(:string, [])

  def spec_for_type({:one_of, values}, opts), do: spec_for_type({:in, values}, opts)

  def spec_for_type({mod, arg}, _opts) when is_atom(mod) and is_list(arg),
    do: {{:module, [], Elixir}, {:list, [], Elixir}}

  def spec_for_type({:map, key_type, value_type}, _opts) do
    {:%{}, [], [{{:optional, [], [spec_for_type(key_type, [])]}, spec_for_type(value_type, [])}]}
  end

  def spec_for_type(:map, opts) do
    spec_for_type({:map, :atom, :any}, opts)
  end

  def spec_for_type(tuple, _opts) when is_tuple(tuple) do
    {:tuple, [], []}
  end

  def spec_for_type([], _), do: []
  def spec_for_type([type], opts), do: [spec_for_type(type, opts)]
end
