defmodule TypedStructor do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MODULEDOC -->", parts: 2)
             |> Enum.fetch!(1)

  @built_in_definers [
    defstruct: TypedStructor.Definer.Defstruct,
    defexception: TypedStructor.Definer.Defexception,
    defrecord: TypedStructor.Definer.Defrecord,
    defrecordp: TypedStructor.Definer.Defrecordp
  ]

  defmacro __using__(_opts) do
    quote do
      import TypedStructor, only: [typed_structor: 1, typed_structor: 2]
    end
  end

  @doc """
  Defines a struct with type information.

  Inside a `typed_structor` block, you can define fields with the `field/3` macro.

  ## Options

    * `:module` - if provided, a new submodule will be created with the struct.
    * `:enforce` - if `true`, the struct will enforce the keys, see `field/3` options for more information.
    * `:definer` - the definer module to use to define the struct, record or exception. Defaults to `:defstruct`. It also accepts a macro that receives the definition struct and returns the AST. See definer section below.
    * `:type_kind` - the kind of type to use for the struct. Defaults to `type`, can be `opaque` or `typep`.
    * `:type_name` - the name of the type to use for the struct. Defaults to `t`.

  ## Definer
  The available definers are:
  - `:defstruct`, which defines a struct and a type for a given definition
  - `:defexception`, which defines an exception and a type for a given definition
  - `:defrecord`, which defines record macros and a type for a given definition
  - `:defrecordp`, which defines private record macros and a type for a given definition

  ### `:defstruct` options

  #{TypedStructor.Definer.Defstruct.__additional_options__()}

  ### `:defexception` options

  #{TypedStructor.Definer.Defexception.__additional_options__()}

  ### `:defrecord` and `:defrecordp` options

  #{TypedStructor.Definer.Defrecord.__additional_options__()}

  ### custom definer

      defmodule MyStruct do
        use TypedStructor

        typed_structor definer: MyDefiner do

          field :name, String.t()
          field :age, integer()
        end
      end

  ## Examples

      defmodule MyStruct do
        use TypedStructor

        typed_structor do
          field :name, String.t()
          field :age, integer()
        end
      end

  Creates the struct in a submodule instead:

      defmodule MyStruct do
        use TypedStructor

        typed_structor module: Struct do
          field :name, String.t()
          field :age, integer()
        end
      end

  To add a `@typedoc` to the struct type and `@moduledoc` to the submodule,
  just add the module attribute in the `typed_structor` block:

      defmodule MyStruct do
        use TypedStructor

        typed_structor module: Struct do
          @typedoc "A typed struct"
          @moduledoc "A submodule"

          field :name, String.t()
          field :age, integer()
        end
      end
  """
  defmacro typed_structor(options \\ [], do: block) when is_list(options) do
    case Keyword.pop(options, :module) do
      {nil, options} ->
        __typed_structor__(__CALLER__, options, block)

      {module, options} ->
        quote do
          defmodule unquote(module) do
            use TypedStructor

            typed_structor unquote(options) do
              unquote(block)
            end
          end
        end
    end
  end

  defp __typed_structor__(caller, options, block) when is_list(options) do
    case fetch_definer!(caller, options) do
      :error -> :ok
      {:ok, definer} -> Module.put_attribute(caller.module, :__ts_definer__, definer)
    end

    Module.register_attribute(caller.module, :__ts_struct_fields__, accumulate: true)
    Module.register_attribute(caller.module, :__ts_struct_parameters__, accumulate: true)
    Module.register_attribute(caller.module, :__ts_struct_plugins__, accumulate: true)
    Module.register_attribute(caller.module, :__ts_definition____, accumulate: false)

    quote do
      # create a lexical scope
      try do
        import TypedStructor,
          only: [field: 2, field: 3, parameter: 1, parameter: 2, plugin: 1, plugin: 2]

        unquote(register_global_plugins())

        unquote(block)
      after
        :ok
      end

      # create a lexical scope
      try do
        definition =
          TypedStructor.__call_plugins_before_definitions__(%TypedStructor.Definition{
            options: unquote(options),
            fields: Enum.reverse(@__ts_struct_fields__),
            parameters: Enum.reverse(@__ts_struct_parameters__)
          })

        @__ts_definition__ definition
      after
        # cleanup
        Module.delete_attribute(__MODULE__, :__ts_struct_fields__)
        Module.delete_attribute(__MODULE__, :__ts_struct_parameters__)
      end

      TypedStructor.__define__(@__ts_definition__)

      # create a lexical scope
      try do
        TypedStructor.__call_plugins_after_definitions__(@__ts_definition__)
      after
        # cleanup
        Module.delete_attribute(__MODULE__, :__ts_struct_plugins__)
        Module.delete_attribute(__MODULE__, :__ts_definition__)
        Module.delete_attribute(__MODULE__, :__ts_definer__)
      end
    end
  end

  defp fetch_definer!(caller, options) when is_list(options) do
    case Keyword.fetch(options, :definer) do
      :error ->
        :error

      {:ok, definer} ->
        case Macro.expand(definer, caller) do
          built_in_or_mod when is_atom(built_in_or_mod) ->
            {:ok, built_in_or_mod}

          other ->
            raise ArgumentError, """
            Definer must be one of :defstruct, :defexception, :defrecord, :defrecordp or a module that defines a `define/1` macro,
            got: #{inspect(other)}
            """
        end
    end
  end

  # register global plugins
  defp register_global_plugins do
    :typed_structor
    |> Application.get_env(:plugins, [])
    |> List.wrap()
    |> Enum.map(fn
      {plugin, opts} when is_atom(plugin) and is_list(opts) ->
        {plugin, opts}

      plugin when is_atom(plugin) ->
        {plugin, []}

      other ->
        raise ArgumentError,
              """
              Expected a plugin module or a tuple with a plugin module and its keyword options,
              Got: #{inspect(other)}

              Example:

                  config :typed_structor, plugins: [
                    {MyPlugin, [option: :value]},
                    MyAnotherPlugin
                  ]
              """
    end)
    |> Enum.map(fn {plugin, opts} ->
      quote do
        plugin unquote(plugin), unquote(opts)
      end
    end)
  end

  @doc """
  Defines a field in a `typed_structor/2`.
  You can override the options set by `typed_structor/2` by passing options.

  ## Example

      # A field named :example of type String.t()
      field :example, String.t()

  ## Options

    * `:default` - sets the default value for the field
    * `:enforce` - if set to `true`, enforces the field, and makes its type
      non-nullable if `:default` is not set

  > ### How `:default` and `:enforce` affect `type` and `@enforce_keys` {: .tip}
  >
  > | **`:default`** | **`:enforce`** | **`type`**        | **`@enforce_keys`** |
  > | -------------- | -------------- | ----------------- | ------------------- |
  > | `set`          | `true`         | `t()`             | `excluded`          |
  > | `set`          | `false`        | `t()`             | `excluded`          |
  > | `unset`        | `true`         | `t()`             | **`included`**      |
  > | `unset`        | `false`        | **`t() \\| nil`** | `excluded`          |
  """
  defmacro field(name, type, options \\ []) do
    options = Keyword.merge(options, name: name, type: Macro.escape(type))

    quote do
      @__ts_struct_fields__ unquote(options)
    end
  end

  @doc """
  Defines a type parameter in a `typed_structor/2`.

  ## Example

      # A type parameter named int
      parameter :int

      field :number, int # not int()
  """
  defmacro parameter(name, opts \\ [])

  defmacro parameter(name, opts) when is_atom(name) and is_list(opts) do
    quote do
      @__ts_struct_parameters__ Keyword.merge(unquote(opts), name: unquote(name))
    end
  end

  defmacro parameter(name, opts) do
    raise ArgumentError,
          "name must be an atom and opts must be a list, got: #{inspect(name)} and #{inspect(opts)}"
  end

  @doc """
  Registers a plugin for the currently defined struct.

  ## Example

      typed_structor do
        plugin MyPlugin

        field :string, String.t()
      end

  For more information on how to define your own plugins, please see
  `TypedStructor.Plugin`. To use a third-party plugin, please refer directly to
  its documentation.
  """
  defmacro plugin(plugin, opts \\ []) when is_list(opts) do
    Module.put_attribute(__CALLER__.module, :__ts_struct_plugins__, {plugin, opts})

    quote do
      require unquote(plugin)

      unquote(plugin).init(unquote(opts))
    end
  end

  defmacro __define__(definition) do
    definer = Module.get_attribute(__CALLER__.module, :__ts_definer__, :defstruct)
    definer_mod = Keyword.get(@built_in_definers, definer, definer)

    quote do
      case Keyword.fetch(unquote(definition).options, :definer) do
        :error ->
          :ok

        {:ok, unquote(definer)} ->
          :ok

        {:ok, other} ->
          IO.warn("""
          The definer option set in the `typed_structor` block is different from the definer option in the definition.
          We will ignore the definer option in the definition and use the one set in the `typed_structor` block.

          Note: The definer option in the definition may be changed by a plugin.

          The effective definer is: #{inspect(unquote(definer))}, the ignored definer from the definition is: #{inspect(other)}.
          """)
      end

      require unquote(definer_mod)
      unquote(definer_mod).define(unquote(definition))
    end
  end

  @doc false
  defmacro __call_plugins_before_definitions__(definition) do
    alias TypedStructor.Definition

    plugins = Module.get_attribute(__CALLER__.module, :__ts_struct_plugins__)

    Enum.reduce(plugins, definition, fn {plugin, opts}, acc ->
      quote do
        require unquote(plugin)

        result = unquote(plugin).before_definition(unquote(acc), unquote(opts))

        result
        |> List.wrap()
        |> Enum.filter(&is_struct(&1, Definition))
        |> case do
          [definition] ->
            definition

          _otherwise ->
            raise """
            The plugin call to `#{inspect(unquote(plugin))}` did not return a `#{inspect(Definition)}` struct,
            got: #{inspect(result)}

            The plugin call should return a `#{inspect(Definition)}` struct,
            or a list which contains exactly one `#{inspect(Definition)}` struct.
            """
        end
      end
    end)
  end

  @doc false
  defmacro __call_plugins_after_definitions__(definition) do
    plugins = Module.get_attribute(__CALLER__.module, :__ts_struct_plugins__)

    for {plugin, opts} <- plugins do
      quote do
        require unquote(plugin)

        unquote(plugin).after_definition(
          unquote(definition),
          unquote(Macro.escape(opts))
        )
      end
    end
  end
end
