defmodule Flint.Schema do
  @moduledoc """
  `Flint.Schema` provides custom implementations of certain `Ecto` `embedded_schema` DSL keywords.

  When you `use Flint`, all of these definitions are imported into the module and override the default
  `Ecto.Schema` implementations. You should not have to directly interact with this module.
  """
  require Logger
  import Ecto.Changeset

  @error_regex ~r"%{(\w+)}"

  @embeds_one_defaults Application.compile_env(:flint, [:embeds_one],
                         defaults_to_struct: true,
                         on_replace: :delete
                       )
  @embeds_one_bang_defaults Application.compile_env(:flint, [:embeds_one!],
                              defaults_to_struct: false,
                              on_replace: :delete
                            )
  @embeds_many_defaults Application.compile_env(:flint, [:embeds_many], on_replace: :delete)
  @embeds_many_bang_defaults Application.compile_env(:flint, [:embeds_many!], on_replace: :delete)

  @default_aliases [
    lt: :less_than,
    gt: :greater_than,
    le: :less_than_or_equal_to,
    ge: :greater_than_or_equal_to,
    eq: :equal_to,
    ne: :not_equal_to
  ]
  @aliases Application.compile_env(:flint, :aliases, @default_aliases)

  @doc false
  def aliases, do: @aliases

  defp make_required(module, name) do
    Module.put_attribute(module, :required, name)
  end

  @doc """
  Dumps the JSON representation of the given schema
  """
  def dump(obj) do
    obj |> Ecto.embedded_dump(:json)
  end

  @doc """
  Wraps `Ecto`'s `field` macro to accept additional options which are consumed by `Flint.Changeset`.

  `Flint` options that are passed to `field` are stripped by `Flint` before passing them to `Ecto.Schema.field` and
  stored in module attributed for the schema's module.

  If no `Flint`-specific options or features are used, this acts the same as `Ecto.Schema.field`.
  """
  defmacro field(name, type \\ :string, opts \\ [])

  defmacro field(name, type, opts) do
    extension_opts =
      Module.get_attribute(__CALLER__.module, :extension_options, [])

    extension_opts_names = Enum.map(extension_opts, & &1.name)

    {extra_opts, opts} =
      Keyword.split(opts, extension_opts_names)

    {block, opts} = Keyword.pop(opts, :block)
    if block, do: Module.put_attribute(__CALLER__.module, :blocks, {name, block})

    {alias_opts, opts} = Keyword.split(opts, Keyword.keys(@aliases))

    extra_opts =
      extra_opts ++
        Enum.map(alias_opts, fn {als, opt} ->
          mapped = Keyword.get(@aliases, als)

          unless mapped in extension_opts_names do
            raise ArgumentError,
                  "Alias #{inspect(als)} in field #{inspect(name)} mapped to invalid option #{inspect(mapped)}. Must be mapped to a value in #{inspect(extension_opts_names)}"
          end

          {mapped, opt}
        end)

    extra_options =
      Enum.map(extension_opts, fn %Flint.Extension.Field{
                                    name: option_name,
                                    default: default,
                                    validator: validator,
                                    required: required,
                                    eval: eval?
                                  } ->
        value =
          cond do
            Keyword.has_key?(extra_opts, option_name) ->
              Keyword.fetch!(extra_opts, option_name)

            !is_nil(default) ->
              default

            true ->
              nil
          end

        value =
          if eval? do
            {value, _bindings} = Code.eval_quoted(value)
            value
          else
            value
          end

        if required && is_nil(value),
          do:
            raise(
              ArgumentError,
              "Required option #{inspect(option_name)} on field #{inspect(name)} not found."
            )

        if not is_nil(value) && validator && not validator.(value),
          do:
            raise(
              ArgumentError,
              "Value #{inspect(value)} for option #{inspect(option_name)} on field #{inspect(name)} failed validation."
            )

        {option_name, value}
      end)

    if length(extra_options) != 0 do
      Module.put_attribute(__CALLER__.module, :extra_options, {name, extra_options})
    end

    quote do
      Ecto.Schema.field(unquote(name), unquote(type), unquote(opts))
    end
  end

  @doc """
  Marks a field as required by storing metadata in a module attribute, then calls `Flint.Schema.field`.

  The metadata tracking is not enforced at the schema / struct level (eg. through enforced keys), but rather
  at the schema validation level (through something such as `Flint.Changeset.changeset`).
  """
  defmacro field!(name, type \\ :string, opts \\ [])

  defmacro field!(name, type, opts) do
    make_required(__CALLER__.module, name)

    quote do
      field(unquote(name), unquote(type), unquote(opts))
    end
  end

  @doc """
  Wraps `Ecto`'s `embeds_one` macro to accept additional options which are consumed by `Flint.Changeset`.

  `Flint` options that are passed to `embeds_one` are stripped by `Flint` before passing them to `Ecto.Schema.embeds_one` and
  stored in module attributed for the schema's module.

  The following default options are passed to `Flint.Schema.embeds_one` and can be overriden at the `Application` level or at the
  local call level.

  ## Default Options

  ```elixir
  defaults_to_struct: true,
  on_replace: :delete
  ```
  """
  defmacro embeds_one(name, schema, opts \\ [])

  defmacro embeds_one(name, schema, do: block) do
    quote do
      embeds_one(unquote(name), unquote(schema), unquote(@embeds_one_defaults),
        do: unquote(block)
      )
    end
  end

  defmacro embeds_one(name, schema, opts) do
    quote do
      Ecto.Schema.embeds_one(
        unquote(name),
        unquote(schema),
        unquote(opts) ++ unquote(@embeds_one_defaults)
      )
    end
  end

  defmacro embeds_one(name, schema, opts, do: block) do
    schema = expand_nested_module_alias(schema, __CALLER__)

    quote do
      {schema, opts} =
        Flint.Schema.__embeds_module__(
          __ENV__,
          unquote(schema),
          unquote(opts) ++ unquote(@embeds_one_defaults),
          unquote(Macro.escape(block))
        )

      Ecto.Schema.__embeds_one__(__MODULE__, unquote(name), schema, opts)
    end
  end

  @doc """
  Marks an embeds_one field as required by storing metadata in a module attribute, then calls `Flint.Schema.embeds_one`.

  The metadata tracking is not enforced at the schema / struct level (eg. through enforced keys), but rather
  at the schema validation level (through something such as `Flint.Changeset.changeset`).

  The following default options are passed to `Flint.Schema.embeds_one!` and can be overriden at the `Application` level or at the
  local call level.

  ## Default Options

  ```elixir
  on_replace: :delete
  ```
  """
  defmacro embeds_one!(name, schema, opts \\ [])

  defmacro embeds_one!(name, schema, do: block) do
    make_required(__CALLER__.module, name)

    quote do
      embeds_one(
        unquote(name),
        unquote(schema),
        unquote(@embeds_one_bang_defaults),
        do: unquote(block)
      )
    end
  end

  defmacro embeds_one!(name, schema, opts) do
    make_required(__CALLER__.module, name)

    quote do
      embeds_one(
        unquote(name),
        unquote(schema),
        unquote(opts) ++ unquote(@embeds_one_bang_defaults)
      )
    end
  end

  defmacro embeds_one!(name, schema, opts, do: block) do
    make_required(__CALLER__.module, name)

    quote do
      embeds_one(
        unquote(name),
        unquote(schema),
        unquote(opts) ++ unquote(@embeds_one_bang_defaults),
        do: unquote(block)
      )
    end
  end

  @doc """
  Wraps `Ecto`'s `embeds_many` macro to accept additional options which are consumed by `Flint.Changeset`.

  `Flint` options that are passed to `embeds_many` are stripped by `Flint` before passing them to `Ecto.Schema.embeds_many` and
  stored in module attributed for the schema's module.

  The following default options are passed to `Flint.Schema.embeds_many` and can be overriden at the `Application` level or at the
  local call level.

  ## Default Options

  ```elixir
  on_replace: :delete
  ```
  """
  defmacro embeds_many(name, schema, opts \\ [])

  defmacro embeds_many(name, schema, do: block) do
    quote do
      embeds_many(unquote(name), unquote(schema), unquote(@embeds_many_defaults),
        do: unquote(block)
      )
    end
  end

  defmacro embeds_many(name, schema, opts) do
    quote do
      Ecto.Schema.embeds_many(
        unquote(name),
        unquote(schema),
        unquote(opts) ++ unquote(@embeds_many_defaults)
      )
    end
  end

  defmacro embeds_many(name, schema, opts, do: block) do
    schema = expand_nested_module_alias(schema, __CALLER__)

    quote do
      {schema, opts} =
        Flint.Schema.__embeds_module__(
          __ENV__,
          unquote(schema),
          unquote(opts) ++ unquote(@embeds_many_bang_defaults),
          unquote(Macro.escape(block))
        )

      Ecto.Schema.__embeds_many__(__MODULE__, unquote(name), schema, opts)
    end
  end

  @doc """
  Marks an embeds_many field as required by storing metadata in a module attribute, then calls `Flint.Schema.embeds_many`.

  The metadata tracking is not enforced at the schema / struct level (eg. through enforced keys), but rather
  at the schema validation level (through something such as `Flint.Changeset.changeset`).

  The following default options are passed to `Flint.Schema.embeds_many!` and can be overriden at the `Application` level or at the
  local call level.

  ## Default Options

  ```elixir
  on_replace: :delete
  ```
  """
  defmacro embeds_many!(name, schema, opts \\ [])

  defmacro embeds_many!(name, schema, do: block) do
    make_required(__CALLER__.module, name)

    quote do
      embeds_many(
        unquote(name),
        unquote(schema),
        unquote(@embeds_many_bang_defaults),
        do: unquote(block)
      )
    end
  end

  defmacro embeds_many!(name, schema, opts) do
    make_required(__CALLER__.module, name)

    quote do
      embeds_many(
        unquote(name),
        unquote(schema),
        unquote(opts) ++ unquote(@embeds_many_bang_defaults)
      )
    end
  end

  defmacro embeds_many!(name, schema, opts, do: block) do
    make_required(__CALLER__.module, name)

    quote do
      embeds_many(
        unquote(name),
        unquote(schema),
        unquote(opts) ++ unquote(@embeds_many_bang_defaults),
        do: unquote(block)
      )
    end
  end

  @doc false
  def __embeds_module__(env, module, opts, block) do
    {pk, opts} = Keyword.pop(opts, :primary_key, false)

    extensions = Module.get_attribute(env.module, :extensions, [])

    block =
      quote do
        use Flint.Schema,
          inherit: false,
          primary_key: unquote(Macro.escape(pk)),
          schema: [
            unquote(block)
          ],
          extensions: unquote(extensions)
      end

    Module.create(module, block, env)
    {module, opts}
  end

  @doc """
  Wraps `Ecto`'s `embedded_schema` macro, injecting `Flint`'s custom macro implementation into the module space.
  """
  defmacro embedded_schema(do: block) do
    quote do
      Ecto.Schema.embedded_schema do
        import Ecto.Schema,
          except: [
            embeds_one: 2,
            embeds_one: 3,
            embeds_one: 4,
            embeds_many: 2,
            embeds_many: 3,
            embeds_many: 4,
            field: 1,
            field: 2,
            field: 3
          ]

        import Flint.Schema
        @after_compile Flint.Schema

        unquote(block)
        unquote_splicing(Module.get_attribute(__CALLER__.module, :extension_fields, []))
      end
    end
  end

  # From https://github.com/elixir-ecto/ecto/blob/1918cdc93d5543c861682fdfb4105a35d21135cc/lib/ecto/schema.ex#L2532
  @doc false
  def expand_nested_module_alias({:__aliases__, _, [Elixir, _ | _] = alias}, _env),
    do: Module.concat(alias)

  def expand_nested_module_alias({:__aliases__, _, [h | t]}, env) when is_atom(h),
    do: Module.concat([env.module, h | t])

  def expand_nested_module_alias(other, _env), do: other

  @doc """
  Creates a new schema struct according to the schema's `changeset` implementation, immediately applying
  the changes from the changeset regardless of errors.

  If you want to manually handle error cases, you should use the `changeset` function itself.
  """
  def new(module, params \\ %{}, bindings \\ []) do
    apply(module, :changeset, [struct!(module), params, bindings])
    |> apply_changes()
  end

  @doc """
  Same as `new`, except will `raise` if any errors exist in the changeset.
  """
  def new!(module, params \\ %{}, bindings \\ []) do
    changeset = apply(module, :changeset, [struct!(module), params, bindings])

    if changeset.valid? do
      apply_changes(changeset)
    else
      message =
        traverse_errors(changeset, fn {msg, opts} ->
          Regex.replace(@error_regex, msg, fn _, key ->
            opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
          end)
        end)

      raise ArgumentError, "#{inspect(struct!(module, Map.merge(params, message)), pretty: true)}"
    end
  end

  defmacro __using__(opts) do
    {schema, opts} = Keyword.pop(opts, :schema)
    {inherit?, opts} = Keyword.pop(opts, :inherit, true)

    {extensions, _opts} =
      Keyword.pop(opts, :extensions, Flint.default_extensions())

    {extensions, _bindings} =
      Code.eval_quoted(
        extensions,
        binding(),
        Map.update(__CALLER__, :aliases, [], fn aliases ->
          (aliases ++
             [
               {Block, Flint.Extensions.Block},
               if(Code.ensure_loaded?(TypedEctoSchema), do: {Typed, Flint.Extensions.Typed}),
               {When, Flint.Extensions.When},
               {Accessible, Flint.Extensions.Accessible},
               {EctoValidations, Flint.Extensions.EctoValidations},
               {Embedded, Flint.Extensions.Embedded},
               {JSON, Flint.Extensions.JSON},
               {PreTransforms, Flint.Extensions.PreTransforms},
               {PostTransforms, Flint.Extensions.PostTransforms}
             ])
          |> Enum.filter(& &1)
        end)
      )

    extension_names =
      extensions
      |> Enum.map(fn
        {ext, _opts} when is_atom(ext) -> ext
        ext when is_atom(ext) -> ext
      end)

    %{attributes: attributes, options: options} =
      [:attributes, :options, :fields, :embeds_ones]
      |> Enum.map(fn entity_type ->
        entities =
          extension_names
          |> Enum.flat_map(fn extension ->
            for attr <- Spark.Dsl.Extension.get_entities(extension, entity_type) do
              {extension, attr}
            end
          end)

        {entity_type, entities}
      end)
      |> Map.new()

    extension_schemas =
      if inherit? do
        for extension <- extension_names,
            row <- extension.template_schema() do
          row
        end
      else
        []
      end

    Module.register_attribute(__CALLER__.module, :required, accumulate: true)
    Module.register_attribute(__CALLER__.module, :blocks, accumulate: true)
    # Extension-Related Attributes
    Module.register_attribute(__CALLER__.module, :__embedded_schema_super__, accumulate: true)
    Module.register_attribute(__CALLER__.module, :extension_attributes, accumulate: true)
    Module.register_attribute(__CALLER__.module, :extension_options, accumulate: true)
    Module.register_attribute(__CALLER__.module, :extra_options, accumulate: true)
    Module.put_attribute(__CALLER__.module, :extensions, extensions)
    Module.put_attribute(__CALLER__.module, :extension_fields, extension_schemas)

    attrs =
      Enum.map(attributes, fn {extension, %{name: name, default: default, validator: validator}} =
                                attr ->
        Module.put_attribute(__CALLER__.module, :extension_attributes, attr)

        if not is_nil(default) do
          if validator && not validator.(default),
            do:
              raise(
                ArgumentError,
                "Default value #{inspect(default)} for attribute #{inspect(name)} registered for extension #{inspect(extension)} failed validation."
              )

          quote do
            Module.put_attribute(
              __MODULE__,
              unquote(name),
              unquote(Macro.escape(default))
            )
          end
        end
      end)
      |> Enum.filter(& &1)

    Enum.each(options, fn {_extension, field} ->
      Module.put_attribute(__CALLER__.module, :extension_options, field)
    end)

    prelude =
      quote do
        alias Flint.Types.Union
        import Flint.Type

        @before_compile Flint.Schema

        def __schema__(:required), do: @required |> Enum.reverse()
        def __schema__(:blocks), do: @blocks |> Enum.reverse()
        # Extension-Related Reflections
        def __schema__(:extensions), do: @extensions
        def __schema__(:extra_options), do: @extra_options |> Enum.reverse()

        defdelegate changeset(schema, params \\ %{}, bindings \\ []), to: Flint.Changeset
        def new(params \\ %{}, bindings \\ []), do: Flint.Schema.new(__MODULE__, params, bindings)

        def new!(params \\ %{}, bindings \\ []),
          do: Flint.Schema.new!(__MODULE__, params, bindings)

        defoverridable new: 0,
                       new: 1,
                       new: 2,
                       new!: 0,
                       new!: 1,
                       new!: 2,
                       changeset: 1,
                       changeset: 2,
                       changeset: 3

        use Ecto.Schema
        import Ecto.Schema, except: [embedded_schema: 1]
        import Flint.Schema, only: [embedded_schema: 1]
        unquote_splicing(attrs)
      end

    using_extensions =
      extensions
      |> Enum.map(fn
        {extension, opts} when is_atom(extension) ->
          quote do
            use unquote(extension), unquote(opts)
          end

        extension when is_atom(extension) ->
          quote do
            use unquote(extension)
          end

        _ ->
          raise ArgumentError, "Error with extension option."
      end)

    if schema do
      quote do
        unquote(prelude)
        unquote_splicing(using_extensions)

        embedded_schema do
          unquote(schema)
        end
      end
    else
      quote do
        unquote(prelude)
        unquote_splicing(using_extensions)
      end
    end
  end

  defmacro __before_compile__(_env) do
    attrs =
      Module.get_attribute(__CALLER__.module, :extension_attributes, [])

    attrs_reflections =
      Enum.map(attrs, fn {extension,
                          %Flint.Extension.Field{
                            name: name,
                            validator: validator,
                            default: default,
                            eval: eval?
                          }} ->
        attr_val = Module.get_attribute(__CALLER__.module, name)

        attr_val =
          if eval? do
            {value, _bindings} = Code.eval_quoted(attr_val)
            value
          else
            attr_val
          end

        if validator && not validator.(attr_val) && attr_val != default,
          do:
            raise(
              ArgumentError,
              "Value #{inspect(attr_val)} for attribute #{inspect(name)} registered for extension #{inspect(extension)} failed validation."
            )

        quote do
          def __schema__(unquote(name)),
            do: unquote(Macro.escape(attr_val))
        end
      end)

    quote do
      def __schema__(:attributes),
        do: unquote(Macro.escape(Enum.group_by(attrs, &elem(&1, 0), &elem(&1, 1))))

      unquote_splicing(attrs_reflections)

      def __schema__(_), do: {:error, "Unknown schema reflection."}
    end
  end

  # I think this is the best way to mimic the parent environment to let you write expressions
  # as though they are taking place inside the parent schema.
  # I considered altering the `embedded_schema` macro to do these imports, but it's being
  # defined while the parent schema is being defined and it will not be available yet
  def __after_compile__(env, _bytecode) do
    imports =
      (env.functions ++ env.macros)
      |> Enum.group_by(fn {module, _} -> module end)
      |> Enum.map(fn {module, lists} ->
        items =
          lists
          |> Enum.flat_map(fn {_, items} -> items end)
          |> Enum.uniq()

        quote do
          import unquote(module), only: unquote(items)
        end
      end)

    contents =
      quote do
        def env do
          import unquote(env.module)
          (unquote_splicing(imports))
          __ENV__
        end
      end

    Module.concat(env.module, Env)
    |> Module.create(contents, Macro.Env.location(env))
  end
end
