defmodule Ecto.Function do
  @moduledoc """
  Helper macro for defining helper macros for calling DB functions.

  A little Xzibity, but helps.
  """

  @doc ~S"""
  Define new SQL function call.

  ## Options

  Currently there is only one option allowed:

  - `for` - define DB function name to call

  ## Examples

      import Ecto.Function

      defqueryfunc foo                    # Define function without params
      defqueryfunc bar(a, b)              # Explicit parameter names
      defqueryfunc baz/1                  # Define function using arity
      defqueryfunc qux(a, b \\ 0)         # Define function with default arguments
      defqueryfunc quux/1, for: "db_quux" # Define with alternative DB call

  Then calling such functions in query would be equivalent to:

      from _ in "foos", select: %{foo: foo()}
      # => SELECT foo() AS foo FROM foos

      from q in "bars", select: %{bar: bar(q.a, q.b)}
      # => SELECT bar(bars.a, bars.b) AS bar FROM bars

      from q in "bazs", where: baz(q.a) == true
      # => SELECT * FROM bazs WHERE baz(bazs.a) = TRUE

      from q in "quxs", select: %{one: qux(q.a), two: qux(q.a, q.b)}
      # => SELECT
      #      qux(quxs.a, 0) AS one,
      #      qux(quxs.a, quxs.b) AS two
      #    FROM "quxs"

      from q in "quuxs", select: %{quux: quux(q.a)}
      # => SELECT db_quux(quuxs.a) FROM quuxs

  ## Gotchas

  If your function uses "special syntax" like PostgreSQL [`extract`][extract]
  then this module won't help you and you will be required to write your own
  macro that will handle such case.

      defmacro extract(from, field) do
        quote do: fragment("extract(? FROM ?)", field, from)
      end

  This case probably will never be supported in this library and you should
  handle it on your own.

  [extract]: https://www.postgresql.org/docs/current/static/functions-datetime.html#functions-datetime-extract
  """
  defmacro defqueryfunc(definition, opts \\ [])

  defmacro defqueryfunc({:/, _, [{name, _, _}, params_count]}, opts)
           when is_atom(name) and is_integer(params_count) do
    require Logger

    opts = Keyword.put_new(opts, :for, name)
    params = Macro.generate_arguments(params_count, Elixir)

    Logger.warn("""
    func/arity syntax is deprecated, instead use:

        defqueryfunc #{Macro.to_string(quote do: unquote(name)(unquote_splicing(params)))}
    """)

    macro(name, params, __CALLER__, opts)
  end

  defmacro defqueryfunc({name, _, params}, opts)
           when is_atom(name) and is_list(params) do
    opts = Keyword.put_new(opts, :for, name)

    macro(name, params, __CALLER__, opts)
  end

  defmacro defqueryfunc({name, _, _}, opts) when is_atom(name) do
    opts = Keyword.put_new(opts, :for, name)

    macro(name, [], __CALLER__, opts)
  end

  defmacro defqueryfunc(tree, _) do
    raise CompileError,
      file: __CALLER__.file,
      line: __CALLER__.line,
      description: "Unexpected query function definition #{Macro.to_string(tree)}."
  end

  defp macro(name, params, caller, opts) do
    sql_name = Keyword.fetch!(opts, :for)
    {query, args} = build_query(params, caller)

    quote do
      defmacro unquote(name)(unquote_splicing(params)) do
        unquote(body(sql_name, query, args))
      end
    end
  end

  defp body(name, query, args) do
    fcall = "#{name}(#{query})"
    args = Enum.map(args, &{:unquote, [], [&1]})

    {:quote, [], [[do: {:fragment, [], [fcall | args]}]]}
  end

  defp build_query(args, caller) do
    query =
      "?"
      |> List.duplicate(Enum.count(args))
      |> Enum.join(",")

    args =
      args
      |> Enum.map(fn
        {:\\, _, [{_, _, _} = arg, _default]} ->
          arg

        {_, _, env} = arg when is_atom(env) ->
          arg

        _token ->
          raise CompileError,
            file: caller.file,
            line: caller.line,
            description: ~S"only variables and \\ are allowed as arguments in definition header."
      end)

    {query, args}
  end
end
