defmodule Dagger.Mod.Object do
  @moduledoc """
  Declare a module as an object type.

  ## Declare an object

  Add `use Dagger.Mod.Object` to the Elixir module that want to be a
  Dagger module and give a name through `:name` configuration:

      defmodule Potato do
        use Dagger.Mod.Object, name: "Potato"

        # ...
      end

  The module also support documentation by using Elixir standard documentation,
  `@moduledoc`.

  ## Declare a function

  The module provides a `defn`, a macro for declare a function.
  Let's declare a new function named `echo` that accepts a `name` as a string
  and return a container that echo a name in the module `Potato` from the previous
  section:

      defmodule Potato do
        use Dagger.Mod.Object, name: "Potato"

        defn echo(name: String.t()) :: Dagger.Container.t() do
          dag()
          |> Dagger.Client.container()
          |> Dagger.Container.from("alpine")
          |> Dagger.Container.with_exec(["echo", name])
        end
      end

  From the example above, the `defn` allows you to annotate a type to function
  arguments and return type by using Elixir Typespec. The type will convert to
  a Dagger type when registering a module.

  The supported primitive types for now are:

  1. `integer()` for a boolean type.
  2. `boolean()` for a boolean type.
  3. `String.t()` or `binary()` for a string type.
  4. `list(type)` or `[type]` for a list type.
  5. `type | nil` for optional type.
  6. Any type that generated under `Dagger` namespace (`Dagger.Container.t()`,
     `Dagger.Directory.t()`, etc.).

  The function also support documentation by using Elixir standard documentation,
  `@doc`.
  """

  @type function_name() :: atom()
  @type function_def() :: {function_name(), keyword()}

  alias Dagger.Mod.Object.Defn
  alias Dagger.Mod.Object.Meta

  @doc """
  Get module documentation.

  Returns module doc string or `nil` if the given module didn't have a documentation.
  """
  @spec get_module_doc(module()) :: String.t() | nil
  def get_module_doc(module) do
    with {module_doc, _} <- fetch_docs(module),
         %{"en" => doc} <- module_doc do
      String.trim(doc)
    else
      :none -> nil
      :hidden -> nil
      {:error, :module_not_found} -> nil
    end
  end

  @doc """
  Get function documentation.

  Return doc string or `nil` if that function didn't have a documentation.
  """
  @spec get_function_doc(module(), function_name()) :: String.t() | nil
  def get_function_doc(module, name) do
    fun = fn
      {{:function, ^name, _}, _, _, _, _} -> true
      _ -> false
    end

    with {_, function_docs} <- fetch_docs(module),
         {{:function, ^name, _}, _, _, doc_content, _} <- Enum.find(function_docs, fun),
         %{"en" => doc} <- doc_content do
      String.trim(doc)
    else
      nil -> nil
      :none -> nil
      :hidden -> nil
    end
  end

  defp fetch_docs(module) do
    {:docs_v1, _, :elixir, _, module_doc, _, function_docs} = Code.fetch_docs(module)
    {module_doc, function_docs}
  end

  defmacro __before_compile__(env) do
    if Module.get_attribute(env.module, :struct_declared) do
      required_fields = Module.get_attribute(env.module, :required_fields) || []
      optional_fields = Module.get_attribute(env.module, :optional_fields) || []
      fields = required_fields ++ optional_fields
      fields = Macro.escape(fields)

      quote do
        defimpl Nestru.Decoder do
          def decode_fields_hint(_empty_struct, _context, _value) do
            {:ok, Dagger.Mod.Object.decoder_hint(unquote(fields))}
          end
        end
      end
    else
      quote do
      end
    end
  end

  defmacro __using__(opts) do
    name = opts[:name]

    quote do
      use Dagger.Core.Base, kind: :object, name: unquote(name)

      import Dagger.Mod.Object, only: [defn: 2, field: 2, field: 3, object: 1]
      import Dagger.Global, only: [dag: 0]

      Module.register_attribute(__MODULE__, :function, accumulate: true, persist: true)
      Module.register_attribute(__MODULE__, :field, accumulate: true, persist: true)

      @before_compile Dagger.Mod.Object

      # Get an object name
      def __object__(:name), do: unquote(name)

      # List available function definitions.
      def __object__(:functions) do
        __MODULE__.__info__(:attributes)
        |> Keyword.get_values(:function)
        |> Enum.flat_map(& &1)
      end

      # Get a function definition.
      def __object__(:function, name) do
        __object__(:functions)
        |> Keyword.fetch!(name)
      end

      # List available field definitions.
      def __object__(:fields) do
        __MODULE__.__info__(:attributes)
        |> Keyword.get_values(:field)
        |> Enum.flat_map(& &1)
      end
    end
  end

  @doc """
  Declare a function.
  """
  defmacro defn(call, do: block) do
    {name, args, return} = extract_call(call)
    has_self? = is_tuple(args)
    arg_defs = compile_args(args)
    return_def = compile_typespec!(return)

    quote do
      @function {unquote(name),
                 %Dagger.Mod.Object.FunctionDef{
                   self: unquote(has_self?),
                   args: unquote(arg_defs),
                   return: unquote(return_def)
                 }}
      unquote(Defn.define(name, args, return, block))
    end
  end

  @doc """
  Declare an object struct.
  """
  defmacro object(do: block) do
    quote do
      Module.register_attribute(__MODULE__, :required_fields, accumulate: true)
      Module.register_attribute(__MODULE__, :optional_fields, accumulate: true)

      unquote(block)

      required_fields = @required_fields || []
      optional_fields = @optional_fields || []
      fields = @required_fields ++ @optional_fields

      # TODO: convert fields into typespec.
      @type t() :: %__MODULE__{}

      @derive Jason.Encoder
      @enforce_keys Keyword.keys(required_fields)
      defstruct fields |> Keyword.keys() |> Enum.sort()

      @struct_declared true
    end
  end

  def decoder_hint(fields) do
    fields
    |> Enum.filter(&only_module/1)
    |> Enum.into(%{}, fn {name, field_def} ->
      type =
        case field_def.type do
          {:list, type} -> type
          {:optional, type} -> type
          type -> type
        end

      {name, type}
    end)
  end

  defp only_module({_, field_def}) do
    case field_def.type do
      {:list, type} -> module?(type)
      {:optional, type} -> module?(type)
      type -> module?(type)
    end
  end

  defp module?(type) do
    {:module, ^type} = Code.ensure_loaded(type)
    function_exported?(type, :__struct__, 0)
  end

  @doc """
  Declare a field.
  """
  defmacro field(name, type, opts \\ []) do
    type = compile_typespec!(type)
    optional? = match?({:optional, _}, type)
    doc = opts[:doc]
    field = Macro.escape({name, %Dagger.Mod.Object.FieldDef{type: type, doc: doc}})

    quote do
      @field unquote(field)
      if unquote(optional?) do
        Module.put_attribute(__MODULE__, :optional_fields, unquote(field))
      else
        Module.put_attribute(__MODULE__, :required_fields, unquote(field))
      end
    end
  end

  defguardp is_self(self) when is_atom(elem(self, 0)) and is_nil(elem(self, 2))
  defguardp is_args(args) when is_list(args)

  defp extract_call({:"::", _, [call_def, return]}) do
    {name, args} = extract_call_def(call_def)
    {name, args, return}
  end

  defp extract_call_def({name, _, []}) do
    {name, []}
  end

  defp extract_call_def({name, _, [self]}) when is_self(self) do
    {name, {self, []}}
  end

  defp extract_call_def({name, _, [args]}) when is_args(args) do
    {name, args}
  end

  defp extract_call_def({name, _, [self, args]}) when is_self(self) and is_args(args) do
    {name, {self, args}}
  end

  defp compile_args({_, args}) do
    compile_args(args)
  end

  defp compile_args(args) do
    for {name, spec} <- args do
      type = compile_typespec!(spec)
      meta = spec |> extract_options() |> Keyword.put(:type, type)
      {name, Meta.validate!(meta)}
    end
  end

  defp compile_typespec!({:integer, _, []}), do: :integer
  defp compile_typespec!({:float, _, []}), do: :float
  defp compile_typespec!({:boolean, _, []}), do: :boolean

  ## List

  defp compile_typespec!({:list, _, [type]}) do
    {:list, compile_typespec!(type)}
  end

  defp compile_typespec!([type]) do
    {:list, compile_typespec!(type)}
  end

  ## Optional

  defp compile_typespec!(
         {{{:., _,
            [
              {:__aliases__, _, [_type]},
              :t
            ]}, _, []} = type, [default: _default_value]}
       ) do
    {:optional, compile_typespec!(type)}
  end

  defp compile_typespec!({:|, _, [type, nil]}) do
    {:optional, compile_typespec!(type)}
  end

  ## Type with options

  defp compile_typespec!({type, _}) do
    compile_typespec!(type)
  end

  ## String

  defp compile_typespec!({:binary, _, []}), do: :string

  defp compile_typespec!(
         {{:., _,
           [
             {:__aliases__, _, [:String]},
             :t
           ]}, _, []}
       ) do
    :string
  end

  defp compile_typespec!({{:., _, [{:__aliases__, _, module}, :t]}, _, []}) do
    Module.concat(module)
  end

  defp compile_typespec!(unsupported_type) do
    raise ArgumentError, "type `#{Macro.to_string(unsupported_type)}` is not supported"
  end

  defp extract_options({_, options}), do: options
  defp extract_options(_), do: []
end
