defmodule Kanta.Query do
  @moduledoc """
    This module is a base for all queries modules in the app.

    Including it into given resource query module is as easy as:
    ```
    use Kanta.Query,
      module: Kanta.ResourceModuleGoesHere,
      binding: :resource_binding_goes_here
    ```
  """

  defmacro __using__(opts \\ []) do
    # credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks
    quote do
      import Ecto.Query

      alias Kanta.Migrations.Postgresql
      alias Kanta.Repo

      # Returns the base for resource query with binding.
      #
      # ## Examples
      #
      # iex> base()
      # #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user
      #
      @spec base :: Ecto.Query.t()
      def base do
        from(_ in unquote(opts[:module]), as: unquote(opts[:binding]))
      end

      def one(query \\ base(), opts \\ []) do
        Repo.get_repo().one(query, opts)
      end

      def all(query \\ base(), opts \\ []) do
        Repo.get_repo().all(query, opts)
      end

      @default_page_size 100
      @minimum_per_page 10

      @spec paginate(Ecto.Query.t(), integer() | nil, integer() | nil) :: map()
      @spec paginate(Ecto.Query.t(), integer() | nil) :: map()
      @spec paginate(Ecto.Query.t()) :: map()

      def paginate(query, page \\ 1, per_page \\ @default_page_size)

      def paginate(query, page, per_page) do
        page = parse_page(page)

        per_page =
          if is_number(per_page), do: max(per_page, @minimum_per_page), else: @default_page_size

        %{
          entries: entries,
          page_number: page_number,
          page_size: page_size,
          total_pages: total_pages,
          total_entries: total_entries
        } =
          Scrivener.paginate(
            query,
            %Scrivener.Config{
              caller: self(),
              module: Repo.get_repo(),
              page_number: page,
              page_size: per_page || @default_page_size,
              options: []
            }
          )

        %{
          entries: entries,
          metadata: %{
            page_number: page_number,
            page_size: page_size,
            total_pages: total_pages,
            total_entries: total_entries
          }
        }
      end

      @doc """
      Returns unique resources.

      ## Examples

          iex> Kanta.Accounts.UserQueries.unique()
          #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user, distinct: true>
      """
      def unique(query \\ base(), column \\ true)
      def unique(query, true), do: distinct(query, true)

      def unique(query, column) when is_atom(column) do
        distinct(
          query,
          [{unquote(opts[:binding]), resource}],
          field(resource, ^column)
        )
      end

      @doc """
      Select specific one column from resource.

      ## Examples

          iex> Kanta.Accounts.UserQueries.select_column(:email)
          #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user, select: u0.email>
      """
      @spec select_column(Ecto.Query.t(), atom()) :: Ecto.Query.t()
      def select_column(query \\ base(), column) do
        select(
          query,
          [{unquote(opts[:binding]), resource}],
          field(resource, ^column)
        )
      end

      @doc """
      Preloads resources for given resource.

      ## Examples

          iex> Kanta.Accounts.UserQueries.preload_resources(:articles)
          #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user,
            preload: [[:articles]]>
      """
      @spec preload_resources(Ecto.Query.t(), keyword()) :: Ecto.Query.t()
      def preload_resources(query \\ base(), preloads) do
        preload(query, ^preloads)
      end

      @doc """
      Counts resources by specific column.

      ## Examples

          iex> Kanta.Accounts.UserQueries.count(:email)
          #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user,
            select: count(u0.email)>
      """
      @spec count(Ecto.Query.t(), atom()) :: Ecto.Query.t()
      def count(query \\ base(), column) do
        select(
          query,
          [{unquote(opts[:binding]), resource}],
          field(resource, ^column) |> count()
        )
      end

      @doc """
      Joins resource with another resource.

      *Important!* The joining has to be defined by join_resource/3 function. Example:
      ```
      defp join_resource(query, :articles, opts) do
        query
        |> join(:left, [user: u], _ in assoc(u, :articles), as: :article)
      end
      ```

      First argument is a query and second argument is pattern-matched atom.

      ## Examples

          iex> Kanta.Accounts.UserQueries.with_join(:articles)
          #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user,
            left_join: u1 in assoc(u0, :articles), as: :article>

      """
      @spec with_join(Ecto.Query.t(), atom()) :: Ecto.Query.t()
      @spec with_join(Ecto.Query.t(), atom(), keyword()) :: Ecto.Query.t()
      def with_join(query \\ base(), resource_name, opts \\ []) when is_atom(resource_name) do
        if has_named_binding?(query, resource_name) do
          query
        else
          join_resource(query, resource_name, opts)
        end
      end

      @doc """
      Filters given resource by specific criterias. Filters should be pass to the function as map `%{"field_name" => filter_value}`.
      It supports associations: `%{"association" => %{"field_name" => filter_value}}`
      and nested associations `%{"association" => %{"nested association" => %{"field_name" => filter_value}}}`

      ## Examples

          iex> Kanta.Accounts.UserQueries.filter_query(%{"email" => "a@a.com"})
          #Ecto.Query<from u0 in Kanta.Accounts.User, as: :user, where: u0.email == ^"a@a.com">
      """
      @spec filter_query(Ecto.Query.t(), map() | keyword() | nil) :: Ecto.Query.t()
      def filter_query(query \\ base(), filters)
      def filter_query(query, nil), do: query

      def filter_query(query, filters) when is_list(filters) do
        filters
        |> Enum.into(%{})
        |> then(&filter_query(query, &1))
      end

      def filter_query(query, filters) do
        Enum.reduce(filters, query, fn
          {association, fields}, q when is_map(fields) ->
            association_atom = maybe_convert_to_atom(association)

            query = from([..., s] in q, join: a in assoc(s, ^association_atom))

            Enum.reduce(fields, query, fn
              {assoc, values}, current_query when is_map(values) ->
                filter_query(current_query, %{assoc => values})

              {field_name, value}, current_query ->
                get_field_name(value, current_query, field_name)
            end)

          {field_name, value}, q ->
            try do
              field_name = maybe_convert_to_atom(field_name)

              get_query_operation(q, value, field_name)
            rescue
              _e ->
                q
            end
        end)
      end

      defp get_query_operation(query, value, field_name) do
        query
        |> maybe_inclusion(value, field_name)
        |> maybe_greater_than(value, field_name)
        |> maybe_greater_or_equal_than(value, field_name)
        |> maybe_lower_than(value, field_name)
        |> maybe_lower_or_equal_than(value, field_name)
        |> maybe_is_null(value, field_name)
        |> maybe_is_not_null(value, field_name)
        |> maybe_equality(value, field_name)
      end

      defp maybe_inclusion(q, value, field_name) do
        if is_list(value) do
          if Enum.all?(value, &String.match?(&1, ~r/(>|>=|<|<=).*/)) do
            combine_inclusion_filters(q, value, field_name)
          else
            from(s in q, where: field(s, ^field_name) in ^value)
          end
        else
          q
        end
      end

      defp combine_inclusion_filters(q, value, field_name) do
        Enum.reduce(value, q, fn value_element, query ->
          get_query_operation(query, value_element, field_name)
        end)
      end

      defp maybe_greater_than(q, value, field_name) do
        if is_binary(value) && String.starts_with?(value, ">") do
          value = String.trim_leading(value, ">")
          from(s in q, where: field(s, ^field_name) > ^value)
        else
          q
        end
      end

      defp maybe_greater_or_equal_than(q, value, field_name) do
        if is_binary(value) && String.starts_with?(value, ">=") do
          value = String.trim_leading(value, ">=")
          from(s in q, where: field(s, ^field_name) >= ^value)
        else
          q
        end
      end

      defp maybe_lower_than(q, value, field_name) do
        if is_binary(value) && String.starts_with?(value, "<") do
          value = String.trim_leading(value, "<")
          from(s in q, where: field(s, ^field_name) < ^value)
        else
          q
        end
      end

      defp maybe_lower_or_equal_than(q, value, field_name) do
        if is_binary(value) && String.starts_with?(value, "<=") do
          value = String.trim_leading(value, "<=")
          from(s in q, where: field(s, ^field_name) <= ^value)
        else
          q
        end
      end

      defp maybe_equality(q, value, field_name) do
        if (is_binary(value) && String.match?(value, ~r/(>|>=|<|<=).*/)) ||
             value in [:is_null, :is_not_null] || is_list(value) do
          q
        else
          from(s in q, where: field(s, ^field_name) == ^value)
        end
      end

      defp maybe_is_null(q, :is_null, field_name) do
        from(s in q, where: is_nil(field(s, ^field_name)))
      end

      defp maybe_is_null(q, _value, field_name), do: q

      defp maybe_is_not_null(q, :is_not_null, field_name) do
        from(s in q, where: not is_nil(field(s, ^field_name)))
      end

      defp maybe_is_not_null(q, _value, field_name), do: q

      defp get_field_name(value, current_query, field_name) when is_binary(field_name) do
        get_field_name(value, current_query, String.to_existing_atom(field_name))
      end

      defp get_field_name(value, current_query, field_name) do
        if is_list(value) do
          field_name =
            from([..., r] in current_query,
              where: field(r, ^field_name) in ^value
            )
        else
          field_name =
            from([..., r] in current_query,
              where: field(r, ^field_name) == ^value
            )
        end
      end

      defp maybe_convert_to_atom(field_name) when is_binary(field_name) do
        String.to_existing_atom(field_name)
      end

      defp maybe_convert_to_atom(field_name) when is_atom(field_name) do
        field_name
      end

      @doc """
      Search for rows by given text.

      ## Examples

          iex> Kanta.Accounts.UserQueries.search_query("some text")
          #Ecto.Query<from u0 in Kanta.Accounts.User, where: fragment("to_tsvector(?::text) @@ plainto_tsquery(?)", u0, ^"some text")>
      """
      @spec search_query(Ecto.Query.t(), any()) :: Ecto.Query.t()
      def search_query(query \\ base(), search)
      def search_query(query, nil), do: query
      def search_query(query, ""), do: query

      def search_query(query, search_term) do
        repo = Repo.get_repo()

        case repo.__adapter__() do
          Ecto.Adapters.Postgres ->
            if Postgresql.migrated_version(%{repo: repo}) >= 2 do
              search_query_fuzzy(query, search_term)
            else
              search_query_legacy(query, search_term)
            end

          _ ->
            or_where(
              query,
              [{unquote(opts[:binding]), resource}],
              like(resource.searchable, ^"%#{search_term}%")
            )
        end
      end

      defmacrop form_search_query(search_term) do
        quote do
          fragment(
            "SELECT to_tsquery(string_agg(unaccent(lexeme) || ':*', ' & ' order by positions)) FROM unnest(to_tsvector(?))",
            unquote(search_term)
          )
        end
      end

      defmacrop ts_rank(left, right) do
        quote do
          fragment("ts_rank(?, ?)", unquote(left), unquote(right))
        end
      end

      defp search_query_fuzzy(query, search_term) do
        query
        |> or_where(
          [{unquote(opts[:binding]), resource}],
          fragment("? @@ ?", resource.searchable, form_search_query(^search_term))
        )
        |> order_by(
          [{unquote(opts[:binding]), resource}],
          desc:
            ts_rank(
              resource.searchable,
              form_search_query(^search_term)
            )
        )
      end

      defp search_query_legacy(query, search_term) do
        from(s in unquote(opts[:module]),
          where:
            fragment(
              "searchable @@ websearch_to_tsquery(?)",
              ^search_term
            ),
          order_by: {
            :desc,
            fragment(
              "ts_rank_cd(searchable, websearch_to_tsquery(?), 4)",
              ^search_term
            )
          }
        )
      end

      @spec order_query(Ecto.Query.t(), keyword()) :: Ecto.Query.t()
      def order_query(query \\ base(), order) do
        order_by(query, [{unquote(opts[:binding]), resource}], ^order)
      end

      @spec undeleted_query(Ecto.Query.t()) :: Ecto.Query.t()
      def undeleted_query(query \\ base()) do
        from(q in unquote(opts[:module]),
          where: is_nil(q.deleted_at),
          where: is_nil(q.deleted_by)
        )
      end

      defmacro null_or_empty(field) do
        quote do
          fragment("(? = '') IS NOT FALSE", unquote(field))
        end
      end

      @spec join_resource(Ecto.Query.t(), atom(), keyword()) :: no_return()
      defp join_resource(_query, _, _opts) do
        raise(ArgumentError, message: "wrong join criteria")
      end

      defoverridable join_resource: 3

      @spec parse_page(page :: any()) :: integer()
      defp parse_page(page) when is_binary(page) do
        case Integer.parse(page) do
          {n, _} ->
            parse_page(n)

          :error ->
            1
        end
      end

      defp parse_page(page) when is_integer(page) do
        max(page, 1)
      end

      defp parse_page(page) when is_float(page) do
        page
        |> floor()
        |> parse_page()
      end

      defp parse_page(_), do: 1
    end
  end
end
